diff --git a/Cargo.lock b/Cargo.lock index e40189c3fc0a9566b6de3d9e69a23e7ce0ba41e7..58be8d591565a1fad66627d8f116478971a535b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,7 +278,6 @@ dependencies = [ "chrono", "client", "collections", - "credentials_provider", "env_logger 0.11.8", "feature_flags", "fs", @@ -307,6 +306,7 @@ dependencies = [ "util", "uuid", "watch", + "zed_credentials_provider", ] [[package]] @@ -2880,6 +2880,7 @@ dependencies = [ "chrono", "clock", "cloud_api_client", + "cloud_api_types", "cloud_llm_client", "collections", "credentials_provider", @@ -2893,6 +2894,7 @@ dependencies = [ "http_client", "http_client_tls", "httparse", + "language_model", "log", "objc2-foundation", "parking_lot", @@ -2924,6 +2926,7 @@ dependencies = [ "util", "windows 0.61.3", "worktree", + "zed_credentials_provider", ] [[package]] @@ -3083,6 +3086,7 @@ dependencies = [ "serde", "serde_json", "text", + "zed_credentials_provider", "zeta_prompt", ] @@ -4059,12 +4063,8 @@ name = "credentials_provider" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", "gpui", - "paths", - "release_channel", "serde", - "serde_json", ] [[package]] @@ -5173,6 +5173,7 @@ dependencies = [ "collections", "copilot", "copilot_ui", + "credentials_provider", "ctor", "db", "edit_prediction_context", @@ -5215,6 +5216,7 @@ dependencies = [ "workspace", "worktree", "zed_actions", + "zed_credentials_provider", "zeta_prompt", "zlog", "zstd", @@ -5641,6 +5643,13 @@ dependencies = [ "log", ] +[[package]] +name = "env_var" +version = "0.1.0" +dependencies = [ + "gpui", +] + [[package]] name = "envy" version = "0.4.2" @@ -7190,7 +7199,6 @@ dependencies = [ "collections", "db", "editor", - "feature_flags", "fs", "git", "git_ui", @@ -7248,7 +7256,6 @@ dependencies = [ "ctor", "db", "editor", - "feature_flags", "file_icons", "futures 0.3.32", "fuzzy", @@ -9385,12 +9392,12 @@ dependencies = [ "anthropic", "anyhow", "base64 0.22.1", - "client", "cloud_api_client", "cloud_api_types", "cloud_llm_client", "collections", "credentials_provider", + "env_var", "futures 0.3.32", "gpui", "http_client", @@ -9406,7 +9413,6 @@ dependencies = [ "smol", "thiserror 2.0.17", "util", - "zed_env_vars", ] [[package]] @@ -10221,6 +10227,7 @@ dependencies = [ "language", "log", "markdown", + "project", "settings", "tempfile", "theme_settings", @@ -13213,6 +13220,7 @@ dependencies = [ "wax", "which 6.0.3", "worktree", + "zed_credentials_provider", "zeroize", "zlog", "ztracing", @@ -15858,6 +15866,7 @@ dependencies = [ "util", "workspace", "zed_actions", + "zed_credentials_provider", ] [[package]] @@ -15973,7 +15982,6 @@ dependencies = [ "agent_ui", "anyhow", "chrono", - "collections", "editor", "feature_flags", "fs", @@ -22292,10 +22300,24 @@ dependencies = [ ] [[package]] -name = "zed_env_vars" +name = "zed_credentials_provider" version = "0.1.0" dependencies = [ + "anyhow", + "credentials_provider", + "futures 0.3.32", "gpui", + "paths", + "release_channel", + "serde", + "serde_json", +] + +[[package]] +name = "zed_env_vars" +version = "0.1.0" +dependencies = [ + "env_var", ] [[package]] @@ -22331,7 +22353,7 @@ dependencies = [ [[package]] name = "zed_glsl" -version = "0.2.2" +version = "0.2.3" dependencies = [ "zed_extension_api 0.1.0", ] @@ -22345,7 +22367,7 @@ dependencies = [ [[package]] name = "zed_proto" -version = "0.3.1" +version = "0.3.2" dependencies = [ "zed_extension_api 0.7.0", ] diff --git a/Cargo.toml b/Cargo.toml index efbbd9f7f3f631a6d79dd8393559f07a9553e5ef..f441ca2f1d75675afbed891a396c0600f3c686e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "crates/edit_prediction_ui", "crates/editor", "crates/encoding_selector", + "crates/env_var", "crates/etw_tracing", "crates/eval_cli", "crates/eval_utils", @@ -220,6 +221,7 @@ members = [ "crates/x_ai", "crates/zed", "crates/zed_actions", + "crates/zed_credentials_provider", "crates/zed_env_vars", "crates/zeta_prompt", "crates/zlog", @@ -309,6 +311,7 @@ dev_container = { path = "crates/dev_container" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } encoding_selector = { path = "crates/encoding_selector" } +env_var = { path = "crates/env_var" } etw_tracing = { path = "crates/etw_tracing" } eval_utils = { path = "crates/eval_utils" } extension = { path = "crates/extension" } @@ -465,6 +468,7 @@ worktree = { path = "crates/worktree" } x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } +zed_credentials_provider = { path = "crates/zed_credentials_provider" } zed_env_vars = { path = "crates/zed_env_vars" } edit_prediction = { path = "crates/edit_prediction" } zeta_prompt = { path = "crates/zeta_prompt" } diff --git a/assets/icons/diff_split.svg b/assets/icons/diff_split.svg index de2056466f7ef1081ee00dabb8b4d5baa8fc9217..dcafeb8df5c28bcac1f1fe8cf5783eebd8d8cd8a 100644 --- a/assets/icons/diff_split.svg +++ b/assets/icons/diff_split.svg @@ -1,5 +1,4 @@ - - - + + diff --git a/assets/icons/diff_split_auto.svg b/assets/icons/diff_split_auto.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9dd7076be75aaf3e90286140a60deece5016114 --- /dev/null +++ b/assets/icons/diff_split_auto.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/diff_unified.svg b/assets/icons/diff_unified.svg index b2d3895ae5466454e9cefc4e77e3c3f2a19cde8c..28735c16f682159b6b0a099176d6fc3b75cd248e 100644 --- a/assets/icons/diff_unified.svg +++ b/assets/icons/diff_unified.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 98053432c5a186ecc886318f2d677f73a62295a2..5ecca68e0404b400af2c285dc51df0a65d6fe07a 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -284,12 +284,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-d": "git::Diff", "shift-alt-y": "agent::KeepAll", @@ -574,6 +598,7 @@ // Change the default action on `menu::Confirm` by setting the parameter // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }], + "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], // Change to open path modal for existing remote connection by setting the parameter // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", @@ -1251,6 +1276,8 @@ "alt-down": "markdown::ScrollDownByItem", "ctrl-home": "markdown::ScrollToTop", "ctrl-end": "markdown::ScrollToBottom", + "find": "buffer_search::Deploy", + "ctrl-f": "buffer_search::Deploy", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f0835a139a39602547d9d8da1cba93eaa7ee82a9..c74b5900001a2c798076783b2741aba84ffc4b15 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -327,12 +327,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-pageup": "agent::ScrollOutputPageUp", + "ctrl-pagedown": "agent::ScrollOutputPageDown", + "ctrl-home": "agent::ScrollOutputToTop", + "ctrl-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-pageup": "agent::ScrollOutputPageUp", + "ctrl-pagedown": "agent::ScrollOutputPageDown", + "ctrl-home": "agent::ScrollOutputToTop", + "ctrl-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage", "shift-ctrl-r": "agent::OpenAgentDiff", "shift-ctrl-d": "git::Diff", "shift-alt-y": "agent::KeepAll", @@ -644,6 +668,7 @@ // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], "alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }], + "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], "cmd-ctrl-b": "branches::OpenRecent", @@ -1351,6 +1376,7 @@ "alt-down": "markdown::ScrollDownByItem", "cmd-up": "markdown::ScrollToTop", "cmd-down": "markdown::ScrollToBottom", + "cmd-f": "buffer_search::Deploy", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 41f36638e1dec40890ddecc6a808c669672e9317..a9eb3933423ff60fe60ac391b12773ce7146fb0d 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -285,12 +285,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-d": "git::Diff", "shift-alt-y": "agent::KeepAll", @@ -1276,6 +1300,8 @@ "alt-down": "markdown::ScrollDownByItem", "ctrl-home": "markdown::ScrollToTop", "ctrl-end": "markdown::ScrollToBottom", + "find": "buffer_search::Deploy", + "ctrl-f": "buffer_search::Deploy", }, }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1a7e7bf77248b6f863d4a6dbc1e268b4c5ae3576..220b44ff537ffa791b23c0c5b7d86b6768d74dc2 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1096,6 +1096,7 @@ "ctrl-e": "markdown::ScrollDown", "g g": "markdown::ScrollToTop", "shift-g": "markdown::ScrollToBottom", + "/": "buffer_search::Deploy", }, }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index 74a4e15a044fa5686441f2e8a587595936ea08fb..5e1eb0e68d2f8a17f89422597aa29b99516333e8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -225,6 +225,11 @@ // 3. Hide on both typing and cursor movement: // "on_typing_and_movement" "hide_mouse": "on_typing_and_movement", + // Determines whether the focused panel follows the mouse location. + "focus_follows_mouse": { + "enabled": false, + "debounce_ms": 250, + }, // Determines how snippets are sorted relative to other completion items. // // 1. Place snippets at the top of the completion list: @@ -1139,6 +1144,11 @@ // // Default: false "show_turn_stats": false, + // Whether to show the merge conflict indicator in the status bar + // that offers to resolve conflicts using the agent. + // + // Default: true + "show_merge_conflict_indicator": true, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index 0d6f4471320e443f3c4a483f53f6901c76e7dc72..bb6c9c04ae14db8f2d01adabd8d1494caa7d7407 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -50,9 +50,9 @@ "show_command": true, // Which edited buffers to save before running the task: // * `all` — save all edited buffers - // * `current` — save current buffer only + // * `current` — save currently active buffer only // * `none` — don't save any buffers - "save": "all", + "save": "none", // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] }, diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 3450e35bf62d780bdaf0cff2c6bc9f8bdfea7c1e..f27566c4f72cac3938a752c64d95d0500c595306 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -283,7 +283,7 @@ "font_weight": null }, "preproc": { - "color": "#bfbdb6ff", + "color": "#ff8f3fff", "font_style": null, "font_weight": null }, @@ -391,6 +391,16 @@ "color": "#5ac1feff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#aad94cff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#f07178ff", + "font_style": null, + "font_weight": null } } } @@ -675,7 +685,7 @@ "font_weight": null }, "preproc": { - "color": "#5c6166ff", + "color": "#fa8d3eff", "font_style": null, "font_weight": null }, @@ -783,6 +793,16 @@ "color": "#3b9ee5ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#6cbf43ff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#ff6666ff", + "font_style": null, + "font_weight": null } } } @@ -1067,7 +1087,7 @@ "font_weight": null }, "preproc": { - "color": "#cccac2ff", + "color": "#ffad65ff", "font_style": null, "font_weight": null }, @@ -1175,6 +1195,16 @@ "color": "#72cffeff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#aad94cff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#f07178ff", + "font_style": null, + "font_weight": null } } } diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 16ae188712f7a800ab4fb8a81a2d24cac99da56b..4330df54fccae55e7ca077c0da9a891ee71ebe3a 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -293,7 +293,7 @@ "font_weight": null }, "preproc": { - "color": "#fbf1c7ff", + "color": "#fb4833ff", "font_style": null, "font_weight": null }, @@ -406,6 +406,16 @@ "color": "#83a598ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#b8bb26ff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#fb4934ff", + "font_style": null, + "font_weight": null } } } @@ -700,7 +710,7 @@ "font_weight": null }, "preproc": { - "color": "#fbf1c7ff", + "color": "#fb4833ff", "font_style": null, "font_weight": null }, @@ -813,6 +823,16 @@ "color": "#83a598ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#b8bb26ff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#fb4934ff", + "font_style": null, + "font_weight": null } } } @@ -1107,7 +1127,7 @@ "font_weight": null }, "preproc": { - "color": "#fbf1c7ff", + "color": "#fb4833ff", "font_style": null, "font_weight": null }, @@ -1220,6 +1240,16 @@ "color": "#83a598ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#b8bb26ff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#fb4934ff", + "font_style": null, + "font_weight": null } } } @@ -1514,7 +1544,7 @@ "font_weight": null }, "preproc": { - "color": "#282828ff", + "color": "#9d0006ff", "font_style": null, "font_weight": null }, @@ -1627,6 +1657,16 @@ "color": "#0b6678ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#79740eff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#9d0006ff", + "font_style": null, + "font_weight": null } } } @@ -1921,7 +1961,7 @@ "font_weight": null }, "preproc": { - "color": "#282828ff", + "color": "#9d0006ff", "font_style": null, "font_weight": null }, @@ -2034,6 +2074,16 @@ "color": "#0b6678ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#79740eff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#9d0006ff", + "font_style": null, + "font_weight": null } } } @@ -2328,7 +2378,7 @@ "font_weight": null }, "preproc": { - "color": "#282828ff", + "color": "#9d0006ff", "font_style": null, "font_weight": null }, @@ -2441,6 +2491,16 @@ "color": "#0b6678ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#79740eff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#9d0006ff", + "font_style": null, + "font_weight": null } } } diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 05af3f5cfeec7d4a24c4fe6d684fb21d04e2d81c..e60b6314b9595ac02bd6a43be4580ba9331ae769 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -290,7 +290,7 @@ "font_weight": null }, "preproc": { - "color": "#dce0e5ff", + "color": "#b477cfff", "font_style": null, "font_weight": null }, @@ -403,6 +403,16 @@ "color": "#73ade9ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#98c379ff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#e06c75ff", + "font_style": null, + "font_weight": null } } } @@ -692,7 +702,7 @@ "font_weight": null }, "preproc": { - "color": "#242529ff", + "color": "#a449abff", "font_style": null, "font_weight": null }, @@ -805,6 +815,16 @@ "color": "#5b79e3ff", "font_style": null, "font_weight": null + }, + "diff.plus": { + "color": "#50a14fff", + "font_style": null, + "font_weight": null + }, + "diff.minus": { + "color": "#e45649ff", + "font_style": null, + "font_weight": null } } } diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b1eb42996af29c739b0b3a9e198d543850693d99..ac7b2d23cb796634fc61022411bb583808f697ef 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -19,7 +19,9 @@ pub enum MentionUri { File { abs_path: PathBuf, }, - PastedImage, + PastedImage { + name: String, + }, Directory { abs_path: PathBuf, }, @@ -155,7 +157,9 @@ impl MentionUri { include_warnings, }) } else if path.starts_with("/agent/pasted-image") { - Ok(Self::PastedImage) + let name = + single_query_param(&url, "name")?.unwrap_or_else(|| "Image".to_string()); + Ok(Self::PastedImage { name }) } else if path.starts_with("/agent/untitled-buffer") { let fragment = url .fragment() @@ -227,7 +231,7 @@ impl MentionUri { .unwrap_or_default() .to_string_lossy() .into_owned(), - MentionUri::PastedImage => "Image".to_string(), + MentionUri::PastedImage { name } => name.clone(), MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(), MentionUri::Rule { name, .. } => name.clone(), @@ -296,7 +300,7 @@ impl MentionUri { MentionUri::File { abs_path } => { FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into()) } - MentionUri::PastedImage => IconName::Image.path().into(), + MentionUri::PastedImage { .. } => IconName::Image.path().into(), MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx) .unwrap_or_else(|| IconName::Folder.path().into()), MentionUri::Symbol { .. } => IconName::Code.path().into(), @@ -322,10 +326,18 @@ impl MentionUri { url.set_path(&abs_path.to_string_lossy()); url } - MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(), + MentionUri::PastedImage { name } => { + let mut url = Url::parse("zed:///agent/pasted-image").unwrap(); + url.query_pairs_mut().append_pair("name", name); + url + } MentionUri::Directory { abs_path } => { let mut url = Url::parse("file:///").unwrap(); - url.set_path(&abs_path.to_string_lossy()); + let mut path = abs_path.to_string_lossy().into_owned(); + if !path.ends_with('/') && !path.ends_with('\\') { + path.push('/'); + } + url.set_path(&path); url } MentionUri::Symbol { @@ -490,6 +502,21 @@ mod tests { assert_eq!(uri.to_uri().to_string(), expected); } + #[test] + fn test_directory_uri_round_trip_without_trailing_slash() { + let uri = MentionUri::Directory { + abs_path: PathBuf::from(path!("/path/to/dir")), + }; + let serialized = uri.to_uri().to_string(); + assert!(serialized.ends_with('/'), "directory URI must end with /"); + let parsed = MentionUri::parse(&serialized, PathStyle::local()).unwrap(); + assert!( + matches!(parsed, MentionUri::Directory { .. }), + "expected Directory variant, got {:?}", + parsed + ); + } + #[test] fn test_parse_symbol_uri() { let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20"); diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs index f0dae2a7b39dcad0fea280a2354f2f3c5c61600b..afaa124de066d92e5a1d1a1670f762017f086d01 100644 --- a/crates/agent/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -1519,7 +1519,7 @@ mod tests { stream: &mut UnboundedReceiver, ) -> Vec { let mut events = Vec::new(); - while let Ok(Some(event)) = stream.try_next() { + while let Ok(event) = stream.try_recv() { events.push(event); } events diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index e7b67e37bf4a8b71664a78b99b757c6985794ec6..ba8b7ed867ea26bcdcdee7f8bf20390c2f9592b3 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -4,7 +4,7 @@ use crate::{ ListDirectoryTool, ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, }; use Role::*; -use client::{Client, UserStore}; +use client::{Client, RefreshLlmTokenListener, UserStore}; use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; @@ -1423,7 +1423,8 @@ impl EditAgentTest { let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); settings::init(cx); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); }); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 49d2d050611e29357401791f49b10f3b0bd1df30..3ffcb016ae2664104962ea722788977fb1fe65ac 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -6,7 +6,7 @@ use acp_thread::{ use agent_client_protocol::schema as acp; use agent_settings::AgentProfileId; use anyhow::Result; -use client::{Client, UserStore}; +use client::{Client, RefreshLlmTokenListener, UserStore}; use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use feature_flags::FeatureFlagAppExt as _; @@ -3253,7 +3253,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let clock = Arc::new(clock::FakeSystemClock::new()); let client = Client::new(clock, http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); LanguageModelRegistry::test(cx); }); @@ -3982,7 +3983,8 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { cx.set_http_client(Arc::new(http_client)); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); } }; @@ -6206,9 +6208,9 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte cx.run_until_parked(); - let event = rx.try_next(); + let event = rx.try_recv(); assert!( - !matches!(event, Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(_))))), + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), "expected no authorization request for allowed .md file" ); } @@ -6350,9 +6352,9 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) cx.run_until_parked(); - let event = rx.try_next(); + let event = rx.try_recv(); assert!( - !matches!(event, Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(_))))), + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), "expected no authorization request for allowed docs.rs URL" ); } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 479dcfc01b699151119bfa4ef66a94e9b63f8563..eb014d2464cdb15cbddb52ab69b23cc3e28fa950 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -253,7 +253,7 @@ impl UserMessage { ) .ok(); } - MentionUri::PastedImage => { + MentionUri::PastedImage { .. } => { debug_panic!("pasted image URI should not be used in mention content") } MentionUri::Directory { .. } => { diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index c67942e5cd3769f814fad62f7311bf7967f3317a..58e779da59aef176464839ed6f2d6a5c16e4bc12 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -595,6 +595,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions, show_turn_stats: false, + show_merge_conflict_indicator: true, new_thread_location: Default::default(), sidebar_side: Default::default(), thinking_display: Default::default(), diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 32d48d2e1435b6e8b454557c337a4603857304c7..063742cef8b888a5d8716afa87787ddfc3c1f9b0 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -382,8 +382,8 @@ mod tests { assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); @@ -449,8 +449,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 631870b4236af2f1140d6510ba7ae31e28c8e206..0e5261d0715907e084e89cd57fb1547431aef785 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -369,8 +369,8 @@ mod tests { assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); @@ -439,8 +439,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index 3051ffc29eba8ed96929c002df2cbb81b1a20c2e..d790896425885e568019588a6a522412f2ec7fc0 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -438,8 +438,8 @@ mod tests { assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); @@ -512,8 +512,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 8f9d23f0007391779a7ae0bd41360a70d034f745..85c17c58e8f2543097a3881cb93e840ee09834a9 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1188,7 +1188,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); // Test 4: Path with .zed in the middle should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); @@ -1251,7 +1251,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); // 5.3: Normal in-project path with allow — no confirmation needed let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); @@ -1268,7 +1268,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); // 5.4: With Confirm default, non-project paths still prompt cx.update(|cx| { @@ -1586,8 +1586,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - stream_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + stream_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); @@ -1658,7 +1658,7 @@ mod tests { } else { auth.await.unwrap(); assert!( - stream_rx.try_next().is_err(), + stream_rx.try_recv().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path @@ -1769,7 +1769,7 @@ mod tests { } else { auth.await.unwrap(); assert!( - stream_rx.try_next().is_err(), + stream_rx.try_recv().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path @@ -1862,7 +1862,7 @@ mod tests { stream_rx.expect_authorization().await; } else { assert!( - stream_rx.try_next().is_err(), + stream_rx.try_recv().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path @@ -1963,7 +1963,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); } } diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs index 6a55517037e54ae4166cd22427201d9325ef0f76..0c6290ec098f9c37a0f6a077daf0a041c013d8ff 100644 --- a/crates/agent/src/tools/evals/streaming_edit_file.rs +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -6,7 +6,7 @@ use crate::{ }; use Role::*; use anyhow::{Context as _, Result}; -use client::{Client, UserStore}; +use client::{Client, RefreshLlmTokenListener, UserStore}; use fs::FakeFs; use futures::{FutureExt, StreamExt, future::LocalBoxFuture}; use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _}; @@ -274,7 +274,8 @@ impl StreamingEditToolTest { cx.set_http_client(http_client); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client, cx); }); diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index be51fcf9188308bd7f15481ce3dc6db208e2c39b..8431648b64a8a082cf27bd7bc216935169f586f8 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -981,13 +981,11 @@ mod tests { "Expected private path validation error, got: {error}" ); - let event = event_rx.try_next(); + let event = event_rx.try_recv(); assert!( !matches!( event, - Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization( - _ - )))) + Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_))) ), "No authorization should be requested when validation fails before listing", ); @@ -1029,13 +1027,11 @@ mod tests { "Normal path should succeed without authorization" ); - let event = event_rx.try_next(); + let event = event_rx.try_recv(); assert!( !matches!( event, - Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization( - _ - )))) + Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_))) ), "No authorization should be requested for normal paths", ); @@ -1086,13 +1082,11 @@ mod tests { "Intra-project symlink should succeed without authorization: {result:?}", ); - let event = event_rx.try_next(); + let event = event_rx.try_recv(); assert!( !matches!( event, - Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization( - _ - )))) + Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_))) ), "No authorization should be requested for intra-project symlinks", ); diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index fcdbd3c59406c35968cab8e92116c17d6dd4d591..4a8aad8455019e776b77e326d5438cedd4518c70 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -389,8 +389,8 @@ mod tests { assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); @@ -456,8 +456,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 5b5f9ca1af6eced21d4bda4b63fe99be8cda040a..37517f6a5cdcf7c1af2ee940b56f1909f823cbd2 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1316,13 +1316,11 @@ mod test { "Expected private-files validation error, got: {error}" ); - let event = event_rx.try_next(); + let event = event_rx.try_recv(); assert!( !matches!( event, - Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization( - _ - )))) + Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_))) ), "No authorization should be requested when validation fails before read", ); diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs index 311ca664333453b4d439e56b194df30ca5cbd987..6953e234e9574c1745e4fd204849584d74b72a88 100644 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -589,8 +589,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); @@ -662,8 +662,8 @@ mod tests { assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs index 7eeeea961f6e5076b60a5663d93f85fe8de816f0..904e9ba8642f1b6cd15d6fa7d285d2969c11d81b 100644 --- a/crates/agent/src/tools/save_file_tool.rs +++ b/crates/agent/src/tools/save_file_tool.rs @@ -584,8 +584,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); @@ -657,8 +657,8 @@ mod tests { assert!( !matches!( - event_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index dfd3310f143d2ad5c8221a2af8d97a0b27c27e89..f977b4d13ceb11679ab63f013d2aef8b6a41b781 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -2493,7 +2493,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); // Test 4: Path with .zed in the middle should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); @@ -2540,7 +2540,7 @@ mod tests { cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.2", &stream_tx, cx)) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); // 5.3: Normal in-project path with allow — no confirmation needed let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); @@ -2554,7 +2554,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); // 5.4: With Confirm default, non-project paths still prompt cx.update(|cx| { @@ -2767,8 +2767,8 @@ mod tests { assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( - stream_rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + stream_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); @@ -2810,7 +2810,7 @@ mod tests { } else { auth.await.unwrap(); assert!( - stream_rx.try_next().is_err(), + stream_rx.try_recv().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path @@ -2887,7 +2887,7 @@ mod tests { } else { auth.await.unwrap(); assert!( - stream_rx.try_next().is_err(), + stream_rx.try_recv().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path @@ -2947,7 +2947,7 @@ mod tests { stream_rx.expect_authorization().await; } else { assert!( - stream_rx.try_next().is_err(), + stream_rx.try_recv().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path @@ -3015,7 +3015,7 @@ mod tests { }) .await .unwrap(); - assert!(stream_rx.try_next().is_err()); + assert!(stream_rx.try_recv().is_err()); } } diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 4d17c0d91dc4d1bff384e89e29a59359151e6fc4..33560f2cf7cc7db6ae0abf6831c1c8050c901308 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -681,17 +681,17 @@ mod tests { ); assert!( !matches!( - rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "invalid command should not request authorization" ); assert!( !matches!( - rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallUpdate( + rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdate::UpdateFields(_) - )))) + ))) ), "invalid command should not emit a terminal card update" ); @@ -810,8 +810,8 @@ mod tests { ); assert!( !matches!( - rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "hardcoded denial should not request authorization" ); @@ -1058,8 +1058,8 @@ mod tests { ); assert!( !matches!( - rx.try_next(), - Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "rejected command {command:?} should not request authorization" ); diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 1afd0aa902ed58aa6dc0fea1b18d4bba58864012..e68c1bfd0eddb0e4072ff7952a3c094dcf4a7995 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -31,7 +31,6 @@ futures.workspace = true gpui.workspace = true feature_flags.workspace = true gpui_tokio = { workspace = true, optional = true } -credentials_provider.workspace = true google_ai.workspace = true http_client.workspace = true indoc.workspace = true @@ -52,6 +51,7 @@ terminal.workspace = true uuid.workspace = true util.workspace = true watch.workspace = true +zed_credentials_provider.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index b07bd4a09a2e8d75402c2fedfd4fe817154d2c2a..b2bc0a9a92cc638386f7ac1eff9f44c278ffe318 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -3,7 +3,6 @@ use acp_thread::AgentConnection; use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result}; use collections::HashSet; -use credentials_provider::CredentialsProvider; use fs::Fs; use gpui::{App, AppContext as _, Entity, Task}; use language_model::{ApiKey, EnvVar}; @@ -392,7 +391,7 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { if let Some(key) = env_var.value { return Task::ready(Ok(key)); } - let credentials_provider = ::global(cx); + let credentials_provider = zed_credentials_provider::global(cx); let api_url = google_ai::API_URL.to_string(); cx.spawn(async move |cx| { Ok( diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index c388569a2c705b91db9511a204a0894cf8158b63..aa9cdb2cc1bd9a09f8905c568aed4ce041cf5570 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,6 +1,7 @@ use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol::schema as acp; +use client::RefreshLlmTokenListener; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::AppContext; use gpui::{Entity, TestAppContext}; @@ -413,7 +414,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { cx.set_http_client(Arc::new(http_client)); let client = client::Client::production(cx); let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); - language_model::init(user_store, client, cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store, cx); #[cfg(test)] project::agent_server_store::AllAgentServersSettings::override_global( diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index c131a686ce10df6c9984ab45bc25a7092cba9295..47ab761027562a7cd5b66fb59c6f2988abfa0701 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -176,6 +176,7 @@ pub struct AgentSettings { pub use_modifier_to_send: bool, pub message_editor_min_lines: usize, pub show_turn_stats: bool, + pub show_merge_conflict_indicator: bool, pub tool_permissions: ToolPermissions, pub new_thread_location: NewThreadLocation, } @@ -629,6 +630,7 @@ impl Settings for AgentSettings { use_modifier_to_send: agent.use_modifier_to_send.unwrap(), message_editor_min_lines: agent.message_editor_min_lines.unwrap(), show_turn_stats: agent.show_turn_stats.unwrap(), + show_merge_conflict_indicator: agent.show_merge_conflict_indicator.unwrap(), tool_permissions: compile_tool_permissions(agent.tool_permissions), new_thread_location: agent.new_thread_location.unwrap_or_default(), } diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 4e3dd63b0337f9be54b550f4f4a6a5ca2e7cdd42..e0df79ba4dfe226652818b120b7bfcc493c73b1e 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -202,6 +202,7 @@ impl ModelInput { .text(cx) .parse::() .map_err(|_| SharedString::from("Max Tokens must be a number"))?, + reasoning_effort: None, capabilities: ModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), @@ -815,7 +816,7 @@ mod tests { cx.set_global(store); theme_settings::init(theme::LoadThemes::JustBase, cx); - language_model::init_settings(cx); + language_model::init(cx); editor::init(cx); }); diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index d5cf63f6cdde9a85a54daaa29f8fc2c6833bdd77..7b70740dd1ac462614a9d08d9e48d7d13ac2ed32 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1809,7 +1809,7 @@ mod tests { cx.set_global(settings_store); prompt_store::init(cx); theme_settings::init(theme::LoadThemes::JustBase, cx); - language_model::init_settings(cx); + language_model::init(cx); }); let fs = FakeFs::new(cx.executor()); @@ -1966,7 +1966,7 @@ mod tests { cx.set_global(settings_store); prompt_store::init(cx); theme_settings::init(theme::LoadThemes::JustBase, cx); - language_model::init_settings(cx); + language_model::init(cx); workspace::register_project_item::(cx); }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4792e0dfebb7e854dc99912b88d3f4bd321b4780..4250303884696e68f4026c682c17a95aa30cf6f5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2076,6 +2076,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if let Some(store) = ThreadMetadataStore::try_global(cx) { + store.update(cx, |store, cx| store.unarchive(&session_id, cx)); + } + if let Some(conversation_view) = self.background_threads.remove(&session_id) { self.set_active_view( ActiveView::AgentThread { conversation_view }, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 0c831b04a98540ca9461efa7a3875e1ece4b4054..57439cd47f99762b383664af77bd095ac7f03557 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -173,6 +173,22 @@ actions!( ToggleThinkingEffortMenu, /// Toggles fast mode for models that support it. ToggleFastMode, + /// Scroll the output by one page up. + ScrollOutputPageUp, + /// Scroll the output by one page down. + ScrollOutputPageDown, + /// Scroll the output up by three lines. + ScrollOutputLineUp, + /// Scroll the output down by three lines. + ScrollOutputLineDown, + /// Scroll the output to the top. + ScrollOutputToTop, + /// Scroll the output to the bottom. + ScrollOutputToBottom, + /// Scroll the output to the previous user message. + ScrollOutputToPreviousMessage, + /// Scroll the output to the next user message. + ScrollOutputToNextMessage, ] ); @@ -718,6 +734,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions: Default::default(), show_turn_stats: false, + show_merge_conflict_indicator: true, new_thread_location: Default::default(), sidebar_side: Default::default(), thinking_display: Default::default(), diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index d618cd2369f4d59834ef838638ae4f3b40de8d02..c42f14f0210f0fe424c2885ce7a1fecbf398634d 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -2144,7 +2144,7 @@ fn build_code_label_for_path( .theme() .syntax() .highlight_id("variable") - .map(HighlightId); + .map(HighlightId::new); let mut label = CodeLabelBuilder::default(); label.push_str(file, None); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 73196e8c01a9196bc6e13d86ffe840cc59e04cae..ded3dc80ba43d801774f8968b73cfbfc1933e196 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -85,8 +85,11 @@ use crate::{ AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, - RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, - ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, + RemoveFirstQueuedMessage, ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, + ScrollOutputPageUp, ScrollOutputToBottom, ScrollOutputToNextMessage, + ScrollOutputToPreviousMessage, ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, + ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, + UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); @@ -828,6 +831,8 @@ impl ConversationView { let count = thread.read(cx).entries().len(); let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0)); + list_state.set_follow_mode(gpui::FollowMode::Tail); + entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); @@ -841,7 +846,7 @@ impl ConversationView { if let Some(scroll_position) = thread.read(cx).ui_scroll_position() { list_state.scroll_to(scroll_position); } else { - list_state.set_follow_tail(true); + list_state.scroll_to_end(); } AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx); @@ -1257,9 +1262,11 @@ impl ConversationView { AcpThreadEvent::EntryUpdated(index) => { if let Some(active) = self.thread_view(&thread_id) { let entry_view_state = active.read(cx).entry_view_state.clone(); + let list_state = active.read(cx).list_state.clone(); entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(*index, thread, window, cx) + view_state.sync_entry(*index, thread, window, cx); }); + list_state.remeasure_items(*index..*index + 1); active.update(cx, |active, cx| { active.auto_expand_streaming_thought(cx); }); @@ -1303,7 +1310,6 @@ impl ConversationView { active.clear_auto_expand_tracking(); if active.list_state.is_following_tail() { active.list_state.scroll_to_end(); - active.list_state.set_follow_tail(false); } } active.sync_generating_indicator(cx); @@ -1381,7 +1387,6 @@ impl ConversationView { active.thread_retry_status.take(); if active.list_state.is_following_tail() { active.list_state.scroll_to_end(); - active.list_state.set_follow_tail(false); } } active.sync_generating_indicator(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index e6fdb9a381c05c6563a8539ec7ea2c87262f87da..7aa5a5436de7ad726a4fc48b6dd8b55745ebfc4d 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -541,31 +541,15 @@ impl ThreadView { let thread_view = cx.entity().downgrade(); this.list_state - .set_scroll_handler(move |event, _window, cx| { + .set_scroll_handler(move |_event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); - let is_following_tail = event.is_following_tail; // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { - if !is_following_tail { - let is_at_bottom = { - let current_offset = - list_state.scroll_px_offset_for_scrollbar().y.abs(); - let max_offset = list_state.max_offset_for_scrollbar().y; - current_offset >= max_offset - px(1.0) - }; - - let is_generating = - matches!(this.thread.read(cx).status(), ThreadStatus::Generating); - - if is_at_bottom && is_generating { - list_state.set_follow_tail(true); - } - } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); @@ -832,13 +816,10 @@ impl ThreadView { } } })); - if self.parent_id.is_none() { - self.suppress_merge_conflict_notification(cx); - } generation } - pub fn stop_turn(&mut self, generation: usize, cx: &mut Context) { + pub fn stop_turn(&mut self, generation: usize, _cx: &mut Context) { if self.turn_fields.turn_generation != generation { return; } @@ -849,25 +830,6 @@ impl ThreadView { .map(|started| started.elapsed()); self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take(); self.turn_fields._turn_timer_task = None; - if self.parent_id.is_none() { - self.unsuppress_merge_conflict_notification(cx); - } - } - - fn suppress_merge_conflict_notification(&self, cx: &mut Context) { - self.workspace - .update(cx, |workspace, cx| { - workspace.suppress_notification(&workspace::merge_conflict_notification_id(), cx); - }) - .ok(); - } - - fn unsuppress_merge_conflict_notification(&self, cx: &mut Context) { - self.workspace - .update(cx, |workspace, _cx| { - workspace.unsuppress(workspace::merge_conflict_notification_id()); - }) - .ok(); } pub fn update_turn_tokens(&mut self, cx: &App) { @@ -1077,7 +1039,7 @@ impl ThreadView { })?; let _ = this.update(cx, |this, cx| { - this.list_state.set_follow_tail(true); + this.list_state.scroll_to_end(); cx.notify(); }); @@ -4978,6 +4940,105 @@ impl ThreadView { cx.notify(); } + fn scroll_output_page_up( + &mut self, + _: &ScrollOutputPageUp, + _window: &mut Window, + cx: &mut Context, + ) { + let page_height = self.list_state.viewport_bounds().size.height; + self.list_state.scroll_by(-page_height * 0.9); + cx.notify(); + } + + fn scroll_output_page_down( + &mut self, + _: &ScrollOutputPageDown, + _window: &mut Window, + cx: &mut Context, + ) { + let page_height = self.list_state.viewport_bounds().size.height; + self.list_state.scroll_by(page_height * 0.9); + cx.notify(); + } + + fn scroll_output_line_up( + &mut self, + _: &ScrollOutputLineUp, + window: &mut Window, + cx: &mut Context, + ) { + self.list_state.scroll_by(-window.line_height() * 3.); + cx.notify(); + } + + fn scroll_output_line_down( + &mut self, + _: &ScrollOutputLineDown, + window: &mut Window, + cx: &mut Context, + ) { + self.list_state.scroll_by(window.line_height() * 3.); + cx.notify(); + } + + fn scroll_output_to_top( + &mut self, + _: &ScrollOutputToTop, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_to_top(cx); + } + + fn scroll_output_to_bottom( + &mut self, + _: &ScrollOutputToBottom, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_to_end(cx); + } + + fn scroll_output_to_previous_message( + &mut self, + _: &ScrollOutputToPreviousMessage, + _window: &mut Window, + cx: &mut Context, + ) { + let entries = self.thread.read(cx).entries(); + let current_ix = self.list_state.logical_scroll_top().item_ix; + if let Some(target_ix) = (0..current_ix) + .rev() + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + { + self.list_state.scroll_to(ListOffset { + item_ix: target_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + } + + fn scroll_output_to_next_message( + &mut self, + _: &ScrollOutputToNextMessage, + _window: &mut Window, + cx: &mut Context, + ) { + let entries = self.thread.read(cx).entries(); + let current_ix = self.list_state.logical_scroll_top().item_ix; + if let Some(target_ix) = (current_ix + 1..entries.len()) + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + { + self.list_state.scroll_to(ListOffset { + item_ix: target_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + } + pub fn open_thread_as_markdown( &self, workspace: Entity, @@ -8541,6 +8602,14 @@ impl Render for ThreadView { .on_action(cx.listener(Self::handle_toggle_command_pattern)) .on_action(cx.listener(Self::open_permission_dropdown)) .on_action(cx.listener(Self::open_add_context_menu)) + .on_action(cx.listener(Self::scroll_output_page_up)) + .on_action(cx.listener(Self::scroll_output_page_down)) + .on_action(cx.listener(Self::scroll_output_line_up)) + .on_action(cx.listener(Self::scroll_output_line_down)) + .on_action(cx.listener(Self::scroll_output_to_top)) + .on_action(cx.listener(Self::scroll_output_to_bottom)) + .on_action(cx.listener(Self::scroll_output_to_previous_message)) + .on_action(cx.listener(Self::scroll_output_to_next_message)) .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| { this.toggle_fast_mode(cx); })) @@ -8728,7 +8797,7 @@ pub(crate) fn open_link( .open_path(path, None, true, window, cx) .detach_and_log_err(cx); } - MentionUri::PastedImage => {} + MentionUri::PastedImage { .. } => {} MentionUri::Directory { abs_path } => { let project = workspace.project(); let Some(entry_id) = project.update(cx, |project, cx| { diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 20e0b702978b7e72a8526b03570854965335310c..39d70790e0d4a18554b2a1c11510e529d921cd1b 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2025,7 +2025,7 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { pub mod evals { use crate::InlineAssistant; use agent::ThreadStore; - use client::{Client, UserStore}; + use client::{Client, RefreshLlmTokenListener, UserStore}; use editor::{Editor, MultiBuffer, MultiBufferOffset}; use eval_utils::{EvalOutput, NoProcessor}; use fs::FakeFs; @@ -2091,7 +2091,8 @@ pub mod evals { client::init(&client, cx); workspace::init(app_state.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); cx.set_global(inline_assistant); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 1bf524c454ecbca86af9573a77937d2466c36de9..9ed3649a3b15566b1213fa4871426c1a93b7f5b4 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -154,7 +154,7 @@ impl MentionSet { MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!( "Untitled buffer selection mentions are not supported for paste" ))), - MentionUri::PastedImage + MentionUri::PastedImage { .. } | MentionUri::TerminalSelection { .. } | MentionUri::MergeConflict { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) @@ -283,7 +283,7 @@ impl MentionSet { include_errors, include_warnings, } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx), - MentionUri::PastedImage => { + MentionUri::PastedImage { .. } => { debug_panic!("pasted image URI should not be included in completions"); Task::ready(Err(anyhow!( "pasted imaged URI should not be included in completions" @@ -739,9 +739,11 @@ pub(crate) async fn insert_images_as_context( return; } - let replacement_text = MentionUri::PastedImage.as_link().to_string(); - for (image, name) in images { + let mention_uri = MentionUri::PastedImage { + name: name.to_string(), + }; + let replacement_text = mention_uri.as_link().to_string(); let Some((text_anchor, multibuffer_anchor)) = editor .update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); @@ -804,7 +806,13 @@ pub(crate) async fn insert_images_as_context( .shared(); mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone()) + mention_set.insert_mention( + crease_id, + MentionUri::PastedImage { + name: name.to_string(), + }, + task.clone(), + ) }); if task @@ -873,7 +881,7 @@ pub(crate) fn paste_images_as_context( Some(window.spawn(cx, async move |mut cx| { use itertools::Itertools; - let default_name: SharedString = MentionUri::PastedImage.name().into(); + let default_name: SharedString = "Image".into(); let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard .into_entries() .filter_map(|entry| match entry { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 0edca0ad52b9846f2c4ad28ad3b2bea18d8dd08b..8bd543913b3559e221ceef5fcca5b542a3545767 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -261,7 +261,7 @@ async fn resolve_pasted_context_items( ) -> (Vec, Vec>) { let mut items = Vec::new(); let mut added_worktrees = Vec::new(); - let default_image_name: SharedString = MentionUri::PastedImage.name().into(); + let default_image_name: SharedString = "Image".into(); for entry in entries { match entry { @@ -812,7 +812,9 @@ impl MessageEditor { ) .uri(match uri { MentionUri::File { .. } => Some(uri.to_uri().to_string()), - MentionUri::PastedImage => None, + MentionUri::PastedImage { .. } => { + Some(uri.to_uri().to_string()) + } other => { debug_panic!( "unexpected mention uri for image: {:?}", @@ -1638,7 +1640,9 @@ impl MessageEditor { let mention_uri = if let Some(uri) = uri { MentionUri::parse(&uri, path_style) } else { - Ok(MentionUri::PastedImage) + Ok(MentionUri::PastedImage { + name: "Image".to_string(), + }) }; let Some(mention_uri) = mention_uri.log_err() else { continue; @@ -4074,6 +4078,11 @@ mod tests { &mut cx, ); + let image_name = temporary_image_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Image") + .to_string(); std::fs::remove_file(&temporary_image_path).expect("remove temp png"); let expected_file_uri = MentionUri::File { @@ -4081,12 +4090,16 @@ mod tests { } .to_uri() .to_string(); - let expected_image_uri = MentionUri::PastedImage.to_uri().to_string(); + let expected_image_uri = MentionUri::PastedImage { + name: image_name.clone(), + } + .to_uri() + .to_string(); editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - format!("[@Image]({expected_image_uri}) [@file.txt]({expected_file_uri}) ") + format!("[@{image_name}]({expected_image_uri}) [@file.txt]({expected_file_uri}) ") ); }); @@ -4094,7 +4107,7 @@ mod tests { assert_eq!(contents.len(), 2); assert!(contents.iter().any(|(uri, mention)| { - *uri == MentionUri::PastedImage && matches!(mention, Mention::Image(_)) + matches!(uri, MentionUri::PastedImage { .. }) && matches!(mention, Mention::Image(_)) })); assert!(contents.iter().any(|(uri, mention)| { *uri == MentionUri::File { diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 1bad3c45e4dece2397a2e026d659fd0fad043a24..963e32af55fda90f49edb0787f7327190c92681f 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -90,6 +90,7 @@ impl ProfileSelector { if let Some((next_profile_id, _)) = profiles.get_index(next_index) { self.provider.set_profile(next_profile_id.clone(), cx); + cx.notify(); } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index c67049d4d58e62b7b63c3acbad07ac9920568d1d..ce32c2e33f210b6a8371890efb2a4e60ec89e7a9 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -91,14 +91,16 @@ impl TimeBucket { } fn fuzzy_match_positions(query: &str, text: &str) -> Option> { - let query = query.to_lowercase(); - let text_lower = text.to_lowercase(); let mut positions = Vec::new(); let mut query_chars = query.chars().peekable(); - for (i, c) in text_lower.chars().enumerate() { - if query_chars.peek() == Some(&c) { - positions.push(i); - query_chars.next(); + for (byte_idx, candidate_char) in text.char_indices() { + if let Some(&query_char) = query_chars.peek() { + if candidate_char.eq_ignore_ascii_case(&query_char) { + positions.push(byte_idx); + query_chars.next(); + } + } else { + break; } } if query_chars.peek().is_none() { @@ -1283,3 +1285,59 @@ impl PickerDelegate for ProjectPickerDelegate { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fuzzy_match_positions_returns_byte_indices() { + // "🔥abc" — the fire emoji is 4 bytes, so 'a' starts at byte 4, 'b' at 5, 'c' at 6. + let text = "🔥abc"; + let positions = fuzzy_match_positions("ab", text).expect("should match"); + assert_eq!(positions, vec![4, 5]); + + // Verify positions are valid char boundaries (this is the assertion that + // panicked before the fix). + for &pos in &positions { + assert!( + text.is_char_boundary(pos), + "position {pos} is not a valid UTF-8 boundary in {text:?}" + ); + } + } + + #[test] + fn test_fuzzy_match_positions_ascii_still_works() { + let positions = fuzzy_match_positions("he", "hello").expect("should match"); + assert_eq!(positions, vec![0, 1]); + } + + #[test] + fn test_fuzzy_match_positions_case_insensitive() { + let positions = fuzzy_match_positions("HE", "hello").expect("should match"); + assert_eq!(positions, vec![0, 1]); + } + + #[test] + fn test_fuzzy_match_positions_no_match() { + assert!(fuzzy_match_positions("xyz", "hello").is_none()); + } + + #[test] + fn test_fuzzy_match_positions_multi_byte_interior() { + // "café" — 'é' is 2 bytes (0xC3 0xA9), so 'f' starts at byte 4, 'é' at byte 5. + let text = "café"; + let positions = fuzzy_match_positions("fé", text).expect("should match"); + // 'c'=0, 'a'=1, 'f'=2, 'é'=3..4 — wait, let's verify: + // Actually: c=1 byte, a=1 byte, f=1 byte, é=2 bytes + // So byte positions: c=0, a=1, f=2, é=3 + assert_eq!(positions, vec![2, 3]); + for &pos in &positions { + assert!( + text.is_char_boundary(pos), + "position {pos} is not a valid UTF-8 boundary in {text:?}" + ); + } + } +} diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index b13f8b6395dc19e8f4b073f40b5c5577d38ee42e..57d4f12f33af2f5d0d09c5fbbd07ae0fde092d91 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -184,7 +184,7 @@ fn open_mention_uri( MentionUri::Fetch { url } => { cx.open_url(url.as_str()); } - MentionUri::PastedImage + MentionUri::PastedImage { .. } | MentionUri::Selection { abs_path: None, .. } | MentionUri::Diagnostics { .. } | MentionUri::TerminalSelection { .. } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 1a3ce059b8116ac7438f3eb0330b47660cc863de..d8da78c53210230597dab49ce297d9fa694e62f1 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -21,6 +21,7 @@ pub enum CliRequest { reuse: bool, env: Option>, user_data_dir: Option, + dev_container: bool, }, } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b8af5896285d3080ca3320a5909b3f58f72de643..41f2d14c1908ac18e7ea297eef19d8d9bd1cf8b5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -118,6 +118,12 @@ struct Args { /// Will attempt to give the correct command to run #[arg(long)] system_specs: bool, + /// Open the project in a dev container. + /// + /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/` + /// configuration is found in the project directory. + #[arg(long)] + dev_container: bool, /// Pairs of file paths to diff. Can be specified multiple times. /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view. #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])] @@ -670,6 +676,7 @@ fn main() -> Result<()> { reuse: args.reuse, env, user_data_dir: user_data_dir_for_thread, + dev_container: args.dev_container, })?; while let Ok(response) = rx.recv() { diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 1edbb3399e4332e2ebd23f812c66697bda72d587..7bbaccb22e0e6c7508240186103e216f83be2f0c 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -22,6 +22,7 @@ base64.workspace = true chrono = { workspace = true, features = ["serde"] } clock.workspace = true cloud_api_client.workspace = true +cloud_api_types.workspace = true cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true @@ -35,6 +36,7 @@ gpui_tokio.workspace = true http_client.workspace = true http_client_tls.workspace = true httparse = "1.10" +language_model.workspace = true log.workspace = true parking_lot.workspace = true paths.workspace = true @@ -60,6 +62,7 @@ tokio.workspace = true url.workspace = true util.workspace = true worktree.workspace = true +zed_credentials_provider.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 6a11a6b924eed3dfd79ff379638ed4085e2b7bcb..dfd9963a0ee52d167f8d4edb0b850f4debed7fd4 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,6 +1,7 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; +mod llm_token; mod proxy; pub mod telemetry; pub mod user; @@ -13,8 +14,9 @@ use async_tungstenite::tungstenite::{ http::{HeaderValue, Request, StatusCode}, }; use clock::SystemClock; -use cloud_api_client::CloudApiClient; use cloud_api_client::websocket_protocol::MessageToClient; +use cloud_api_client::{ClientApiError, CloudApiClient}; +use cloud_api_types::OrganizationId; use credentials_provider::CredentialsProvider; use feature_flags::FeatureFlagAppExt as _; use futures::{ @@ -24,6 +26,7 @@ use futures::{ }; use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env}; +use language_model::LlmApiToken; use parking_lot::{Mutex, RwLock}; use postage::watch; use proxy::connect_proxy_stream; @@ -51,6 +54,7 @@ use tokio::net::TcpStream; use url::Url; use util::{ConnectionResult, ResultExt}; +pub use llm_token::*; pub use rpc::*; pub use telemetry_events::Event; pub use user::*; @@ -339,7 +343,7 @@ pub struct ClientCredentialsProvider { impl ClientCredentialsProvider { pub fn new(cx: &App) -> Self { Self { - provider: ::global(cx), + provider: zed_credentials_provider::global(cx), } } @@ -568,6 +572,10 @@ impl Client { self.http.clone() } + pub fn credentials_provider(&self) -> Arc { + self.credentials_provider.provider.clone() + } + pub fn cloud_client(&self) -> Arc { self.cloud_client.clone() } @@ -1513,6 +1521,66 @@ impl Client { }) } + pub async fn acquire_llm_token( + &self, + llm_token: &LlmApiToken, + organization_id: Option, + ) -> Result { + let system_id = self.telemetry().system_id().map(|x| x.to_string()); + let cloud_client = self.cloud_client(); + match llm_token + .acquire(&cloud_client, system_id, organization_id) + .await + { + Ok(token) => Ok(token), + Err(ClientApiError::Unauthorized) => { + self.request_sign_out(); + Err(ClientApiError::Unauthorized).context("Failed to create LLM token") + } + Err(err) => Err(anyhow::Error::from(err)), + } + } + + pub async fn refresh_llm_token( + &self, + llm_token: &LlmApiToken, + organization_id: Option, + ) -> Result { + let system_id = self.telemetry().system_id().map(|x| x.to_string()); + let cloud_client = self.cloud_client(); + match llm_token + .refresh(&cloud_client, system_id, organization_id) + .await + { + Ok(token) => Ok(token), + Err(ClientApiError::Unauthorized) => { + self.request_sign_out(); + return Err(ClientApiError::Unauthorized).context("Failed to create LLM token"); + } + Err(err) => return Err(anyhow::Error::from(err)), + } + } + + pub async fn clear_and_refresh_llm_token( + &self, + llm_token: &LlmApiToken, + organization_id: Option, + ) -> Result { + let system_id = self.telemetry().system_id().map(|x| x.to_string()); + let cloud_client = self.cloud_client(); + match llm_token + .clear_and_refresh(&cloud_client, system_id, organization_id) + .await + { + Ok(token) => Ok(token), + Err(ClientApiError::Unauthorized) => { + self.request_sign_out(); + return Err(ClientApiError::Unauthorized).context("Failed to create LLM token"); + } + Err(err) => return Err(anyhow::Error::from(err)), + } + } + pub async fn sign_out(self: &Arc, cx: &AsyncApp) { self.state.write().credentials = None; self.cloud_client.clear_credentials(); diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs new file mode 100644 index 0000000000000000000000000000000000000000..f62aa6dd4dc3462bc3a0f6f46c35f0e4e5499816 --- /dev/null +++ b/crates/client/src/llm_token.rs @@ -0,0 +1,116 @@ +use super::{Client, UserStore}; +use cloud_api_types::websocket_protocol::MessageToClient; +use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME}; +use gpui::{ + App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription, +}; +use language_model::LlmApiToken; +use std::sync::Arc; + +pub trait NeedsLlmTokenRefresh { + /// Returns whether the LLM token needs to be refreshed. + fn needs_llm_token_refresh(&self) -> bool; +} + +impl NeedsLlmTokenRefresh for http_client::Response { + fn needs_llm_token_refresh(&self) -> bool { + self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some() + || self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some() + } +} + +enum TokenRefreshMode { + Refresh, + ClearAndRefresh, +} + +pub fn global_llm_token(cx: &App) -> LlmApiToken { + RefreshLlmTokenListener::global(cx) + .read(cx) + .llm_api_token + .clone() +} + +struct GlobalRefreshLlmTokenListener(Entity); + +impl Global for GlobalRefreshLlmTokenListener {} + +pub struct LlmTokenRefreshedEvent; + +pub struct RefreshLlmTokenListener { + client: Arc, + user_store: Entity, + llm_api_token: LlmApiToken, + _subscription: Subscription, +} + +impl EventEmitter for RefreshLlmTokenListener {} + +impl RefreshLlmTokenListener { + pub fn register(client: Arc, user_store: Entity, cx: &mut App) { + let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx)); + cx.set_global(GlobalRefreshLlmTokenListener(listener)); + } + + pub fn global(cx: &App) -> Entity { + GlobalRefreshLlmTokenListener::global(cx).0.clone() + } + + fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { + client.add_message_to_client_handler({ + let this = cx.weak_entity(); + move |message, cx| { + if let Some(this) = this.upgrade() { + Self::handle_refresh_llm_token(this, message, cx); + } + } + }); + + let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { + if matches!(event, super::user::Event::OrganizationChanged) { + this.refresh(TokenRefreshMode::ClearAndRefresh, cx); + } + }); + + Self { + client, + user_store, + llm_api_token: LlmApiToken::default(), + _subscription: subscription, + } + } + + fn refresh(&self, mode: TokenRefreshMode, cx: &mut Context) { + let client = self.client.clone(); + let llm_api_token = self.llm_api_token.clone(); + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); + cx.spawn(async move |this, cx| { + match mode { + TokenRefreshMode::Refresh => { + client + .refresh_llm_token(&llm_api_token, organization_id) + .await?; + } + TokenRefreshMode::ClearAndRefresh => { + client + .clear_and_refresh_llm_token(&llm_api_token, organization_id) + .await?; + } + } + this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent)) + }) + .detach_and_log_err(cx); + } + + fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { + match message { + MessageToClient::UserUpdated => { + this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx)); + } + } + } +} diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index 0daaee8fb1420c76757ca898655e8dd1a5244d7e..801221d3128b8aa2d25175e086a741d5d85da626 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -22,6 +22,7 @@ log.workspace = true serde.workspace = true serde_json.workspace = true text.workspace = true +zed_credentials_provider.workspace = true zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 3930e2e873a91618bfae456bc188bbd90ffa64b9..7685fa8f5b1eae9e98a621484602e199c2b76f96 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -48,9 +48,10 @@ pub fn codestral_api_key(cx: &App) -> Option> { } pub fn load_codestral_api_key(cx: &mut App) -> Task> { + let credentials_provider = zed_credentials_provider::global(cx); let api_url = codestral_api_url(cx); codestral_api_key_state(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(api_url, |s| s, cx) + key_state.load_if_needed(api_url, |s| s, credentials_provider, cx) }) } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 2a87d617ebb19117ca87c00cc0887b07e416c8bd..75175372f24a83cfb50e8f87deae93e3f03e1a8a 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -65,6 +65,7 @@ CREATE TABLE "worktrees" ( "scan_id" INTEGER NOT NULL, "is_complete" BOOL NOT NULL DEFAULT FALSE, "completed_scan_id" INTEGER NOT NULL, + "root_repo_common_dir" VARCHAR, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 8a56b9ce982f9a39a14bfc55fe8a34870ddea1c6..0110dd149b1143a3edcf76a1e0b18fbf1a22287c 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -484,7 +484,8 @@ CREATE TABLE public.worktrees ( visible boolean NOT NULL, scan_id bigint NOT NULL, is_complete boolean DEFAULT false NOT NULL, - completed_scan_id bigint + completed_scan_id bigint, + root_repo_common_dir character varying ); ALTER TABLE ONLY public.breakpoints ALTER COLUMN id SET DEFAULT nextval('public.breakpoints_id_seq'::regclass); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 3e4c36631b29d35871cac101542bcc6904fbb271..44abc37af66e3f169d3af1a7d5e29063e382c620 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -559,6 +559,7 @@ pub struct RejoinedWorktree { pub settings_files: Vec, pub scan_id: u64, pub completed_scan_id: u64, + pub root_repo_common_dir: Option, } pub struct LeftRoom { @@ -638,6 +639,7 @@ pub struct Worktree { pub settings_files: Vec, pub scan_id: u64, pub completed_scan_id: u64, + pub root_repo_common_dir: Option, } #[derive(Debug)] diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 3fc59f96332180d7d7bca4b6f71a345d9699e9e2..b1ea638072a30d6b881a711448223449aa9f53e2 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -87,6 +87,7 @@ impl Database { visible: ActiveValue::set(worktree.visible), scan_id: ActiveValue::set(0), completed_scan_id: ActiveValue::set(0), + root_repo_common_dir: ActiveValue::set(None), } })) .exec(&*tx) @@ -203,6 +204,7 @@ impl Database { visible: ActiveValue::set(worktree.visible), scan_id: ActiveValue::set(0), completed_scan_id: ActiveValue::set(0), + root_repo_common_dir: ActiveValue::set(None), })) .on_conflict( OnConflict::columns([worktree::Column::ProjectId, worktree::Column::Id]) @@ -266,6 +268,7 @@ impl Database { ActiveValue::default() }, abs_path: ActiveValue::set(update.abs_path.clone()), + root_repo_common_dir: ActiveValue::set(update.root_repo_common_dir.clone()), ..Default::default() }) .exec(&*tx) @@ -761,6 +764,7 @@ impl Database { settings_files: Default::default(), scan_id: db_worktree.scan_id as u64, completed_scan_id: db_worktree.completed_scan_id as u64, + root_repo_common_dir: db_worktree.root_repo_common_dir, legacy_repository_entries: Default::default(), }, ) diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 3197d142cba7a1969e6fdb9423dc94497f6ca53c..94e003fd2d27c97a53f66606d11ed2e15609b728 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -629,6 +629,7 @@ impl Database { settings_files: Default::default(), scan_id: db_worktree.scan_id as u64, completed_scan_id: db_worktree.completed_scan_id as u64, + root_repo_common_dir: db_worktree.root_repo_common_dir, }; let rejoined_worktree = rejoined_project diff --git a/crates/collab/src/db/tables/worktree.rs b/crates/collab/src/db/tables/worktree.rs index 46d9877dff152cdc3b30531606febec65595fec1..f67a9749a48e51fce81f97ad2faf8609c50a0204 100644 --- a/crates/collab/src/db/tables/worktree.rs +++ b/crates/collab/src/db/tables/worktree.rs @@ -15,6 +15,7 @@ pub struct Model { pub scan_id: i64, /// The last scan that fully completed. pub completed_scan_id: i64, + pub root_repo_common_dir: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e05df1909db1e8afed0c06425d84799ff985f3c5..7ed488b0ba62c10326a0e2154f0d2ba895e20a4f 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1485,6 +1485,7 @@ fn notify_rejoined_projects( worktree_id: worktree.id, abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, + root_repo_common_dir: worktree.root_repo_common_dir, updated_entries: worktree.updated_entries, removed_entries: worktree.removed_entries, scan_id: worktree.scan_id, @@ -1943,6 +1944,7 @@ async fn join_project( worktree_id, abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, + root_repo_common_dir: worktree.root_repo_common_dir, updated_entries: worktree.entries, removed_entries: Default::default(), scan_id: worktree.scan_id, diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index a64233caba014aa49bd64f98634b40abeef88e8e..fdaacd768444bd44d8414247f922f38afb7e81d5 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::{self, Path, PathBuf}; use call::ActiveCall; use client::RECEIVE_TIMEOUT; @@ -17,6 +17,61 @@ use workspace::{MultiWorkspace, Workspace}; use crate::TestServer; +#[gpui::test] +async fn test_root_repo_common_dir_sync( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + // Set up a project whose root IS a git repository. + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + executor.run_until_parked(); + + // Host should see root_repo_common_dir pointing to .git at the root. + let host_common_dir = project_a.read_with(cx_a, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + worktree.read(cx).snapshot().root_repo_common_dir().cloned() + }); + assert_eq!( + host_common_dir.as_deref(), + Some(path::Path::new(path!("/project/.git"))), + ); + + // Share the project and have client B join. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + // Guest should see the same root_repo_common_dir as the host. + let guest_common_dir = project_b.read_with(cx_b, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + worktree.read(cx).snapshot().root_repo_common_dir().cloned() + }); + assert_eq!( + guest_common_dir, host_common_dir, + "guest should see the same root_repo_common_dir as host", + ); +} + fn collect_diff_stats( panel: &gpui::Entity, cx: &C, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 91385b298dc661c4a79e4fb52d5be0f38672bff5..8d0cdf351163dadf0ac8cbf6a8dc04886f30f583 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -13,12 +13,13 @@ use db::kvp::KeyValueStore; use editor::{Editor, EditorElement, EditorStyle}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ - AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent, - Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement, - KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, - Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions, - anchored, canvas, deferred, div, fill, list, point, prelude::*, px, + AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div, + Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset, + ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task, + TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point, + prelude::*, px, }; + use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious}; use project::{Fs, Project}; use rpc::{ @@ -43,6 +44,9 @@ use workspace::{ notifications::{DetachAndPromptErr, NotifyResultExt}, }; +const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels"; +const FAVORITE_CHANNELS_KEY: &str = "favorite_channels"; + actions!( collab_panel, [ @@ -243,7 +247,9 @@ pub struct CollabPanel { fs: Arc, focus_handle: FocusHandle, channel_clipboard: Option, - pending_serialization: Task>, + pending_panel_serialization: Task>, + pending_favorites_serialization: Task>, + pending_filter_serialization: Task>, context_menu: Option<(Entity, Point, Subscription)>, list_state: ListState, filter_editor: Entity, @@ -259,7 +265,7 @@ pub struct CollabPanel { subscriptions: Vec, collapsed_sections: Vec
, collapsed_channels: Vec, - filter_active_channels: bool, + filter_occupied_channels: bool, workspace: WeakEntity, } @@ -377,7 +383,9 @@ impl CollabPanel { focus_handle: cx.focus_handle(), channel_clipboard: None, fs: workspace.app_state().fs.clone(), - pending_serialization: Task::ready(None), + pending_panel_serialization: Task::ready(None), + pending_favorites_serialization: Task::ready(None), + pending_filter_serialization: Task::ready(None), context_menu: None, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), channel_name_editor, @@ -392,7 +400,7 @@ impl CollabPanel { match_candidates: Vec::default(), collapsed_sections: vec![Section::Offline], collapsed_channels: Vec::default(), - filter_active_channels: false, + filter_occupied_channels: false, workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), }; @@ -473,8 +481,22 @@ impl CollabPanel { }); } + let filter_occupied_channels = KeyValueStore::global(cx) + .read_kvp(FILTER_OCCUPIED_CHANNELS_KEY) + .ok() + .flatten() + .is_some(); + + panel.update(cx, |panel, cx| { + panel.filter_occupied_channels = filter_occupied_channels; + + if filter_occupied_channels { + panel.update_entries(false, cx); + } + }); + let favorites: Vec = KeyValueStore::global(cx) - .read_kvp("favorite_channels") + .read_kvp(FAVORITE_CHANNELS_KEY) .ok() .flatten() .and_then(|json| serde_json::from_str::>(&json).ok()) @@ -519,7 +541,7 @@ impl CollabPanel { }; let kvp = KeyValueStore::global(cx); - self.pending_serialization = cx.background_spawn( + self.pending_panel_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, @@ -779,14 +801,14 @@ impl CollabPanel { channels.retain(|chan| channel_ids_of_matches_or_parents.contains(&chan.id)); - if self.filter_active_channels { - let active_channel_ids_or_ancestors: HashSet<_> = channel_store + if self.filter_occupied_channels { + let occupied_channel_ids_or_ancestors: HashSet<_> = channel_store .ordered_channels() .map(|(_, channel)| channel) .filter(|channel| !channel_store.channel_participants(channel.id).is_empty()) .flat_map(|channel| channel.parent_path.iter().copied().chain(Some(channel.id))) .collect(); - channels.retain(|channel| active_channel_ids_or_ancestors.contains(&channel.id)); + channels.retain(|channel| occupied_channel_ids_or_ancestors.contains(&channel.id)); } if let Some(state) = &self.channel_editing_state @@ -795,7 +817,7 @@ impl CollabPanel { self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } - let should_respect_collapse = query.is_empty() && !self.filter_active_channels; + let should_respect_collapse = query.is_empty() && !self.filter_occupied_channels; let mut collapse_depth = None; for (idx, channel) in channels.into_iter().enumerate() { @@ -1091,27 +1113,30 @@ impl CollabPanel { room.read(cx).local_participant().role == proto::ChannelRole::Admin }); + let end_slot = if is_pending { + Label::new("Calling").color(Color::Muted).into_any_element() + } else if is_current_user { + IconButton::new("leave-call", IconName::Exit) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Leave Call")) + .on_click(move |_, window, cx| Self::leave_call(window, cx)) + .into_any_element() + } else if role == proto::ChannelRole::Guest { + Label::new("Guest").color(Color::Muted).into_any_element() + } else if role == proto::ChannelRole::Talker { + Label::new("Mic only") + .color(Color::Muted) + .into_any_element() + } else { + Empty.into_any_element() + }; + ListItem::new(user.github_login.clone()) .start_slot(Avatar::new(user.avatar_uri.clone())) .child(render_participant_name_and_handle(user)) .toggle_state(is_selected) - .end_slot(if is_pending { - Label::new("Calling").color(Color::Muted).into_any_element() - } else if is_current_user { - IconButton::new("leave-call", IconName::Exit) - .style(ButtonStyle::Subtle) - .on_click(move |_, window, cx| Self::leave_call(window, cx)) - .tooltip(Tooltip::text("Leave Call")) - .into_any_element() - } else if role == proto::ChannelRole::Guest { - Label::new("Guest").color(Color::Muted).into_any_element() - } else if role == proto::ChannelRole::Talker { - Label::new("Mic only") - .color(Color::Muted) - .into_any_element() - } else { - div().into_any_element() - }) + .end_slot(end_slot) + .tooltip(Tooltip::text("Click to Follow")) .when_some(peer_id, |el, peer_id| { if role == proto::ChannelRole::Guest { return el; @@ -1156,6 +1181,7 @@ impl CollabPanel { .into(); ListItem::new(project_id as usize) + .height(px(24.)) .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.workspace @@ -1173,9 +1199,13 @@ impl CollabPanel { })) .start_slot( h_flex() - .gap_1() + .gap_1p5() .child(render_tree_branch(is_last, false, window, cx)) - .child(IconButton::new(0, IconName::Folder)), + .child( + Icon::new(IconName::Folder) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .child(Label::new(project_name.clone())) .tooltip(Tooltip::text(format!("Open {}", project_name))) @@ -1192,12 +1222,17 @@ impl CollabPanel { let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize); ListItem::new(("screen", id)) + .height(px(24.)) .toggle_state(is_selected) .start_slot( h_flex() - .gap_1() + .gap_1p5() .child(render_tree_branch(is_last, false, window, cx)) - .child(IconButton::new(0, IconName::Screen)), + .child( + Icon::new(IconName::Screen) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .child(Label::new("Screen")) .when_some(peer_id, |this, _| { @@ -1208,7 +1243,7 @@ impl CollabPanel { }) .ok(); })) - .tooltip(Tooltip::text("Open shared screen")) + .tooltip(Tooltip::text("Open Shared Screen")) }) } @@ -1232,7 +1267,9 @@ impl CollabPanel { ) -> impl IntoElement { let channel_store = self.channel_store.read(cx); let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id); + ListItem::new("channel-notes") + .height(px(24.)) .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.open_channel_notes(channel_id, window, cx); @@ -1240,17 +1277,25 @@ impl CollabPanel { .start_slot( h_flex() .relative() - .gap_1() + .gap_1p5() .child(render_tree_branch(false, true, window, cx)) - .child(IconButton::new(0, IconName::File)) - .children(has_channel_buffer_changed.then(|| { - div() - .w_1p5() - .absolute() - .right(px(2.)) - .top(px(2.)) - .child(Indicator::dot().color(Color::Info)) - })), + .child( + h_flex() + .child( + Icon::new(IconName::Reader) + .size(IconSize::Small) + .color(Color::Muted), + ) + .when(has_channel_buffer_changed, |this| { + this.child( + div() + .absolute() + .top_neg_0p5() + .right_0() + .child(Indicator::dot().color(Color::Info)), + ) + }), + ), ) .child(Label::new("notes")) .tooltip(Tooltip::text("Open Channel Notes")) @@ -1946,6 +1991,26 @@ impl CollabPanel { self.channel_store.read(cx).is_channel_favorited(channel_id) } + fn persist_filter_occupied_channels(&mut self, cx: &mut Context) { + let is_enabled = self.filter_occupied_channels; + let kvp_store = KeyValueStore::global(cx); + self.pending_filter_serialization = cx.background_spawn( + async move { + if is_enabled { + kvp_store + .write_kvp(FILTER_OCCUPIED_CHANNELS_KEY.to_string(), "1".to_string()) + .await?; + } else { + kvp_store + .delete_kvp(FILTER_OCCUPIED_CHANNELS_KEY.to_string()) + .await?; + } + anyhow::Ok(()) + } + .log_err(), + ); + } + fn persist_favorites(&mut self, cx: &mut Context) { let favorite_ids: Vec = self .channel_store @@ -1955,11 +2020,11 @@ impl CollabPanel { .map(|id| id.0) .collect(); let kvp_store = KeyValueStore::global(cx); - self.pending_serialization = cx.background_spawn( + self.pending_favorites_serialization = cx.background_spawn( async move { let json = serde_json::to_string(&favorite_ids)?; kvp_store - .write_kvp("favorite_channels".to_string(), json) + .write_kvp(FAVORITE_CHANNELS_KEY.to_string(), json) .await?; anyhow::Ok(()) } @@ -2819,14 +2884,15 @@ impl CollabPanel { Some( h_flex() .child( - IconButton::new("filter-active-channels", IconName::ListFilter) + IconButton::new("filter-occupied-channels", IconName::ListFilter) .icon_size(IconSize::Small) - .toggle_state(self.filter_active_channels) + .toggle_state(self.filter_occupied_channels) .on_click(cx.listener(|this, _, _window, cx| { - this.filter_active_channels = !this.filter_active_channels; + this.filter_occupied_channels = !this.filter_occupied_channels; this.update_entries(true, cx); + this.persist_filter_occupied_channels(cx); })) - .tooltip(Tooltip::text(if self.filter_active_channels { + .tooltip(Tooltip::text(if self.filter_occupied_channels { "Show All Channels" } else { "Show Occupied Channels" @@ -3144,10 +3210,14 @@ impl CollabPanel { (IconName::Star, Color::Default, "Add to Favorites") }; + let height = px(24.); + h_flex() .id(ix) .group("") + .h(height) .w_full() + .overflow_hidden() .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { cx.new(|_| DraggedChannelView { @@ -3175,6 +3245,7 @@ impl CollabPanel { ) .child( ListItem::new(ix) + .height(height) // Add one level of depth for the disclosure arrow. .indent_level(depth + 1) .indent_step_size(px(20.)) @@ -3256,12 +3327,13 @@ impl CollabPanel { .child( h_flex() .visible_on_hover("") + .h_full() .absolute() .right_0() .px_1() .gap_px() - .bg(cx.theme().colors().background) .rounded_l_md() + .bg(cx.theme().colors().background) .child({ let focus_handle = self.focus_handle.clone(); IconButton::new("channel_favorite", favorite_icon) @@ -3335,9 +3407,8 @@ fn render_tree_branch( ) -> impl IntoElement { let rem_size = window.rem_size(); let line_height = window.text_style().line_height_in_pixels(rem_size); - let width = rem_size * 1.5; let thickness = px(1.); - let color = cx.theme().colors().text; + let color = cx.theme().colors().icon_disabled; canvas( |_, _, _| {}, @@ -3367,8 +3438,8 @@ fn render_tree_branch( )); }, ) - .w(width) - .h(line_height) + .w(rem_size) + .h(line_height - px(2.)) } fn render_participant_name_and_handle(user: &User) -> impl IntoElement { diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index 8fa94b775bd270809e5b26aa7fe8478ad6378170..1a314de2fca9b9987336decb15b208ffd7759dea 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -2733,10 +2733,7 @@ mod tests { assert!(refreshed); assert_eq!(provider.access_token().as_deref(), Some("new-access")); - let notified_session = rx - .try_next() - .unwrap() - .expect("channel should have a session"); + let notified_session = rx.try_recv().expect("channel should have a session"); assert_eq!(notified_session.tokens.access_token, "new-access"); assert_eq!( notified_session.tokens.refresh_token.as_deref(), @@ -2768,10 +2765,7 @@ mod tests { let refreshed = provider.try_refresh().await.unwrap(); assert!(refreshed); - let notified_session = rx - .try_next() - .unwrap() - .expect("channel should have a session"); + let notified_session = rx.try_recv().expect("channel should have a session"); assert_eq!(notified_session.tokens.access_token, "new-access"); assert_eq!( notified_session.tokens.refresh_token.as_deref(), diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 6f69bc6bc7bea4ec31aa59262a4abc5640999a2e..e789a89df65daf45dd02a16d954b299307e0c62d 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1045,7 +1045,7 @@ mod tests { }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - assert!(copilot_requests.try_next().is_err()); + assert!(copilot_requests.try_recv().is_err()); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -1055,7 +1055,7 @@ mod tests { }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - assert!(copilot_requests.try_next().is_ok()); + assert!(copilot_requests.try_recv().is_ok()); } fn handle_copilot_completion_request( diff --git a/crates/credentials_provider/Cargo.toml b/crates/credentials_provider/Cargo.toml index bf47bb24b12b90d54bc04f766efe06489c730b43..da83c0cd79a1b71bbb84746b3e893f33094783d6 100644 --- a/crates/credentials_provider/Cargo.toml +++ b/crates/credentials_provider/Cargo.toml @@ -13,9 +13,5 @@ path = "src/credentials_provider.rs" [dependencies] anyhow.workspace = true -futures.workspace = true gpui.workspace = true -paths.workspace = true -release_channel.workspace = true serde.workspace = true -serde_json.workspace = true diff --git a/crates/credentials_provider/src/credentials_provider.rs b/crates/credentials_provider/src/credentials_provider.rs index 249b8333e114223aa558cd33637fd103294a8f8d..b98e97673cc11272826af24c76e8a0a6a38b9211 100644 --- a/crates/credentials_provider/src/credentials_provider.rs +++ b/crates/credentials_provider/src/credentials_provider.rs @@ -1,26 +1,8 @@ -use std::collections::HashMap; use std::future::Future; -use std::path::PathBuf; use std::pin::Pin; -use std::sync::{Arc, LazyLock}; use anyhow::Result; -use futures::FutureExt as _; -use gpui::{App, AsyncApp}; -use release_channel::ReleaseChannel; - -/// An environment variable whose presence indicates that the system keychain -/// should be used in development. -/// -/// By default, running Zed in development uses the development credentials -/// provider. Setting this environment variable allows you to interact with the -/// system keychain (for instance, if you need to test something). -/// -/// Only works in development. Setting this environment variable in other -/// release channels is a no-op. -static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock = LazyLock::new(|| { - std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty()) -}); +use gpui::AsyncApp; /// A provider for credentials. /// @@ -50,150 +32,3 @@ pub trait CredentialsProvider: Send + Sync { cx: &'a AsyncApp, ) -> Pin> + 'a>>; } - -impl dyn CredentialsProvider { - /// Returns the global [`CredentialsProvider`]. - pub fn global(cx: &App) -> Arc { - // The `CredentialsProvider` trait has `Send + Sync` bounds on it, so it - // seems like this is a false positive from Clippy. - #[allow(clippy::arc_with_non_send_sync)] - Self::new(cx) - } - - fn new(cx: &App) -> Arc { - let use_development_provider = match ReleaseChannel::try_global(cx) { - Some(ReleaseChannel::Dev) => { - // In development we default to using the development - // credentials provider to avoid getting spammed by relentless - // keychain access prompts. - // - // However, if the `ZED_DEVELOPMENT_USE_KEYCHAIN` environment - // variable is set, we will use the actual keychain. - !*ZED_DEVELOPMENT_USE_KEYCHAIN - } - Some(ReleaseChannel::Nightly | ReleaseChannel::Preview | ReleaseChannel::Stable) - | None => false, - }; - - if use_development_provider { - Arc::new(DevelopmentCredentialsProvider::new()) - } else { - Arc::new(KeychainCredentialsProvider) - } - } -} - -/// A credentials provider that stores credentials in the system keychain. -struct KeychainCredentialsProvider; - -impl CredentialsProvider for KeychainCredentialsProvider { - fn read_credentials<'a>( - &'a self, - url: &'a str, - cx: &'a AsyncApp, - ) -> Pin)>>> + 'a>> { - async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local() - } - - fn write_credentials<'a>( - &'a self, - url: &'a str, - username: &'a str, - password: &'a [u8], - cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { - cx.update(move |cx| cx.write_credentials(url, username, password)) - .await - } - .boxed_local() - } - - fn delete_credentials<'a>( - &'a self, - url: &'a str, - cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local() - } -} - -/// A credentials provider that stores credentials in a local file. -/// -/// This MUST only be used in development, as this is not a secure way of storing -/// credentials on user machines. -/// -/// Its existence is purely to work around the annoyance of having to constantly -/// re-allow access to the system keychain when developing Zed. -struct DevelopmentCredentialsProvider { - path: PathBuf, -} - -impl DevelopmentCredentialsProvider { - fn new() -> Self { - let path = paths::config_dir().join("development_credentials"); - - Self { path } - } - - fn load_credentials(&self) -> Result)>> { - let json = std::fs::read(&self.path)?; - let credentials: HashMap)> = serde_json::from_slice(&json)?; - - Ok(credentials) - } - - fn save_credentials(&self, credentials: &HashMap)>) -> Result<()> { - let json = serde_json::to_string(credentials)?; - std::fs::write(&self.path, json)?; - - Ok(()) - } -} - -impl CredentialsProvider for DevelopmentCredentialsProvider { - fn read_credentials<'a>( - &'a self, - url: &'a str, - _cx: &'a AsyncApp, - ) -> Pin)>>> + 'a>> { - async move { - Ok(self - .load_credentials() - .unwrap_or_default() - .get(url) - .cloned()) - } - .boxed_local() - } - - fn write_credentials<'a>( - &'a self, - url: &'a str, - username: &'a str, - password: &'a [u8], - _cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { - let mut credentials = self.load_credentials().unwrap_or_default(); - credentials.insert(url.to_string(), (username.to_string(), password.to_vec())); - - self.save_credentials(&credentials) - } - .boxed_local() - } - - fn delete_credentials<'a>( - &'a self, - url: &'a str, - _cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { - let mut credentials = self.load_credentials()?; - credentials.remove(url); - - self.save_credentials(&credentials) - } - .boxed_local() - } -} diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 6a6ac706ecd7e4e3e7369afe503652b9756b6dec..2c653217716b0218cff0b60eb2bce4ac1ce02e5d 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -1086,6 +1086,7 @@ impl SearchableItem for DapLogView { // DAP log is read-only. replacement: false, selection: false, + select_all: true, } } fn active_match_index( diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index 4429c63a37a87d1b54455b8169359ddf40511e24..de970674a4d4ae7b9b583b924addd433d8a03073 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -72,7 +72,11 @@ impl Display for MountDefinition { f, "type={},source={},target={},consistency=cached", self.mount_type.clone().unwrap_or_else(|| { - if self.source.starts_with('/') { + if self.source.starts_with('/') + || self.source.starts_with("\\\\") + || self.source.get(1..3) == Some(":\\") + || self.source.get(1..3) == Some(":/") + { "bind".to_string() } else { "volume".to_string() @@ -253,13 +257,6 @@ impl DevContainer { } return DevContainerBuildType::None; } - - pub(crate) fn has_features(&self) -> bool { - self.features - .as_ref() - .map(|features| !features.is_empty()) - .unwrap_or(false) - } } // Custom deserializer that parses the entire customizations object as a @@ -1355,4 +1352,52 @@ mod test { assert_eq!(devcontainer.build_type(), DevContainerBuildType::Dockerfile); } + + #[test] + fn mount_definition_should_use_bind_type_for_unix_absolute_paths() { + let mount = MountDefinition { + source: "/home/user/project".to_string(), + target: "/workspaces/project".to_string(), + mount_type: None, + }; + + let rendered = mount.to_string(); + + assert!( + rendered.starts_with("type=bind,"), + "Expected mount type 'bind' for Unix absolute path, but got: {rendered}" + ); + } + + #[test] + fn mount_definition_should_use_bind_type_for_windows_unc_paths() { + let mount = MountDefinition { + source: "\\\\server\\share\\project".to_string(), + target: "/workspaces/project".to_string(), + mount_type: None, + }; + + let rendered = mount.to_string(); + + assert!( + rendered.starts_with("type=bind,"), + "Expected mount type 'bind' for Windows UNC path, but got: {rendered}" + ); + } + + #[test] + fn mount_definition_should_use_bind_type_for_windows_absolute_paths() { + let mount = MountDefinition { + source: "C:\\Users\\mrg\\cli".to_string(), + target: "/workspaces/cli".to_string(), + mount_type: None, + }; + + let rendered = mount.to_string(); + + assert!( + rendered.starts_with("type=bind,"), + "Expected mount type 'bind' for Windows absolute path, but got: {rendered}" + ); + } } diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index 1c2863f96118b5bac006f3a590da8cf8980994e2..d28014bffff146ece8cc69f63753ecf5f82a33ea 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -20,7 +20,8 @@ use crate::{ }, docker::{ Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, - DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config, + DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs, + get_remote_dir_from_config, }, features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref}, get_oci_token, @@ -316,13 +317,6 @@ impl DevContainerManifest { let root_image_tag = self.get_base_image_from_config().await?; let root_image = self.docker_client.inspect(&root_image_tag).await?; - if dev_container.build_type() == DevContainerBuildType::Image - && !dev_container.has_features() - { - log::debug!("No resources to download. Proceeding with just the image"); - return Ok(()); - } - let temp_base = std::env::temp_dir().join("devcontainer-zed"); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -700,10 +694,29 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true } let dev_container = self.dev_container(); match dev_container.build_type() { - DevContainerBuildType::Image | DevContainerBuildType::Dockerfile => { + DevContainerBuildType::Image => { let built_docker_image = self.build_docker_image().await?; + let Some(base_image) = dev_container.image.as_ref() else { + log::error!("Dev container is using and image which can't be referenced"); + return Err(DevContainerError::DevContainerParseFailed); + }; let built_docker_image = self - .update_remote_user_uid(built_docker_image, None) + .update_remote_user_uid(built_docker_image, base_image) + .await?; + + let resources = self.build_merged_resources(built_docker_image)?; + Ok(DevContainerBuildResources::Docker(resources)) + } + DevContainerBuildType::Dockerfile => { + let built_docker_image = self.build_docker_image().await?; + let Some(features_build_info) = &self.features_build_info else { + log::error!( + "Can't attempt to build update UID dockerfile before initial docker build" + ); + return Err(DevContainerError::DevContainerParseFailed); + }; + let built_docker_image = self + .update_remote_user_uid(built_docker_image, &features_build_info.image_tag) .await?; let resources = self.build_merged_resources(built_docker_image)?; @@ -815,7 +828,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true let (main_service_name, main_service) = find_primary_service(&docker_compose_resources, self)?; - let built_service_image = if main_service + let (built_service_image, built_service_image_tag) = if main_service .build .as_ref() .map(|b| b.dockerfile.as_ref()) @@ -904,16 +917,19 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true self.docker_client .docker_compose_build(&docker_compose_resources.files, &self.project_name()) .await?; - self.docker_client - .inspect(&features_build_info.image_tag) - .await? + ( + self.docker_client + .inspect(&features_build_info.image_tag) + .await?, + &features_build_info.image_tag, + ) } else if let Some(image) = &main_service.image { if dev_container .features .as_ref() .is_none_or(|features| features.is_empty()) { - self.docker_client.inspect(image).await? + (self.docker_client.inspect(image).await?, image) } else { if !supports_buildkit { self.build_feature_content_image().await?; @@ -993,9 +1009,12 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true .docker_compose_build(&docker_compose_resources.files, &self.project_name()) .await?; - self.docker_client - .inspect(&features_build_info.image_tag) - .await? + ( + self.docker_client + .inspect(&features_build_info.image_tag) + .await?, + &features_build_info.image_tag, + ) } } else { log::error!("Docker compose must have either image or dockerfile defined"); @@ -1003,7 +1022,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true }; let built_service_image = self - .update_remote_user_uid(built_service_image, Some(&features_build_info.image_tag)) + .update_remote_user_uid(built_service_image, built_service_image_tag) .await?; let resources = self.build_merged_resources(built_service_image)?; @@ -1052,7 +1071,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true network_mode_service: Option<&str>, resources: DockerBuildResources, ) -> Result { - let mut runtime_labels = vec![]; + let mut runtime_labels = HashMap::new(); if let Some(metadata) = &resources.image.config.labels.metadata { let serialized_metadata = serde_json_lenient::to_string(metadata).map_err(|e| { @@ -1060,14 +1079,11 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true DevContainerError::ContainerNotValid(resources.image.id.clone()) })?; - runtime_labels.push(format!( - "{}={}", - "devcontainer.metadata", serialized_metadata - )); + runtime_labels.insert("devcontainer.metadata".to_string(), serialized_metadata); } for (k, v) in self.identifying_labels() { - runtime_labels.push(format!("{}={}", k, v)); + runtime_labels.insert(k.to_string(), v.to_string()); } let config_volumes: HashMap = resources @@ -1140,18 +1156,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true // If the main service uses a different service's network bridge, append to that service's ports instead if let Some(network_service_name) = network_mode_service { if let Some(service) = service_declarations.get_mut(network_service_name) { - service.ports.push(format!("{port}:{port}")); + service.ports.push(DockerComposeServicePort { + target: port.clone(), + published: port.clone(), + ..Default::default() + }); } else { service_declarations.insert( network_service_name.to_string(), DockerComposeService { - ports: vec![format!("{port}:{port}")], + ports: vec![DockerComposeServicePort { + target: port.clone(), + published: port.clone(), + ..Default::default() + }], ..Default::default() }, ); } } else { - main_service.ports.push(format!("{port}:{port}")); + main_service.ports.push(DockerComposeServicePort { + target: port.clone(), + published: port.clone(), + ..Default::default() + }); } } let other_service_ports: Vec<(&str, &str)> = forward_ports @@ -1174,12 +1202,20 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true .collect(); for (service_name, port) in other_service_ports { if let Some(service) = service_declarations.get_mut(service_name) { - service.ports.push(format!("{port}:{port}")); + service.ports.push(DockerComposeServicePort { + target: port.to_string(), + published: port.to_string(), + ..Default::default() + }); } else { service_declarations.insert( service_name.to_string(), DockerComposeService { - ports: vec![format!("{port}:{port}")], + ports: vec![DockerComposeServicePort { + target: port.to_string(), + published: port.to_string(), + ..Default::default() + }], ..Default::default() }, ); @@ -1189,18 +1225,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true if let Some(port) = &self.dev_container().app_port { if let Some(network_service_name) = network_mode_service { if let Some(service) = service_declarations.get_mut(network_service_name) { - service.ports.push(format!("{port}:{port}")); + service.ports.push(DockerComposeServicePort { + target: port.clone(), + published: port.clone(), + ..Default::default() + }); } else { service_declarations.insert( network_service_name.to_string(), DockerComposeService { - ports: vec![format!("{port}:{port}")], + ports: vec![DockerComposeServicePort { + target: port.clone(), + published: port.clone(), + ..Default::default() + }], ..Default::default() }, ); } } else { - main_service.ports.push(format!("{port}:{port}")); + main_service.ports.push(DockerComposeServicePort { + target: port.clone(), + published: port.clone(), + ..Default::default() + }); } } @@ -1282,7 +1330,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true async fn update_remote_user_uid( &self, image: DockerInspect, - _override_tag: Option<&str>, + _base_image: &str, ) -> Result { Ok(image) } @@ -1290,7 +1338,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true async fn update_remote_user_uid( &self, image: DockerInspect, - override_tag: Option<&str>, + base_image: &str, ) -> Result { let dev_container = self.dev_container(); @@ -1364,18 +1412,13 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true DevContainerError::FilesystemError })?; - let updated_image_tag = override_tag - .map(|t| t.to_string()) - .unwrap_or_else(|| format!("{}-uid", features_build_info.image_tag)); + let updated_image_tag = format!("{}-uid", features_build_info.image_tag); let mut command = Command::new(self.docker_client.docker_cli()); command.args(["build"]); command.args(["-f", &dockerfile_path.display().to_string()]); command.args(["-t", &updated_image_tag]); - command.args([ - "--build-arg", - &format!("BASE_IMAGE={}", features_build_info.image_tag), - ]); + command.args(["--build-arg", &format!("BASE_IMAGE={}", base_image)]); command.args(["--build-arg", &format!("REMOTE_USER={}", remote_user)]); command.args(["--build-arg", &format!("NEW_UID={}", host_uid)]); command.args(["--build-arg", &format!("NEW_GID={}", host_gid)]); @@ -2292,23 +2335,21 @@ fn get_remote_user_from_config( { return Ok(user.clone()); } - let Some(metadata) = &docker_config.config.labels.metadata else { - log::error!("Could not locate metadata"); - return Err(DevContainerError::ContainerNotValid( - docker_config.id.clone(), - )); - }; - for metadatum in metadata { - if let Some(remote_user) = metadatum.get("remoteUser") { - if let Some(remote_user_str) = remote_user.as_str() { - return Ok(remote_user_str.to_string()); + if let Some(metadata) = &docker_config.config.labels.metadata { + for metadatum in metadata { + if let Some(remote_user) = metadatum.get("remoteUser") { + if let Some(remote_user_str) = remote_user.as_str() { + return Ok(remote_user_str.to_string()); + } } } } - log::error!("Could not locate the remote user"); - Err(DevContainerError::ContainerNotValid( - docker_config.id.clone(), - )) + if let Some(image_user) = &docker_config.config.image_user { + if !image_user.is_empty() { + return Ok(image_user.to_string()); + } + } + Ok("root".to_string()) } // This should come from spec - see the docs @@ -2332,7 +2373,7 @@ fn get_container_user_from_config( return Ok(image_user.to_string()); } - Err(DevContainerError::DevContainerParseFailed) + Ok("root".to_string()) } #[cfg(test)] @@ -2356,6 +2397,8 @@ mod test { use serde_json_lenient::Value; use util::{command::Command, paths::SanitizedPath}; + #[cfg(not(target_os = "windows"))] + use crate::docker::DockerComposeServicePort; use crate::{ DevContainerConfig, DevContainerContext, command_json::CommandRunner, @@ -3526,11 +3569,11 @@ ENV DOCKER_BUILDKIT=1 cap_add: Some(vec!["SYS_PTRACE".to_string()]), security_opt: Some(vec!["seccomp=unconfined".to_string()]), privileged: Some(true), - labels: Some(vec![ - "devcontainer.metadata=[{\"remoteUser\":\"vscode\"}]".to_string(), - "devcontainer.local_folder=/path/to/local/project".to_string(), - "devcontainer.config_file=/path/to/local/project/.devcontainer/devcontainer.json".to_string() - ]), + labels: Some(HashMap::from([ + ("devcontainer.metadata".to_string(), "[{\"remoteUser\":\"vscode\"}]".to_string()), + ("devcontainer.local_folder".to_string(), "/path/to/local/project".to_string()), + ("devcontainer.config_file".to_string(), "/path/to/local/project/.devcontainer/devcontainer.json".to_string()) + ])), volumes: vec![ MountDefinition { source: "dind-var-lib-docker-42dad4b4ca7b8ced".to_string(), @@ -3545,10 +3588,26 @@ ENV DOCKER_BUILDKIT=1 "db".to_string(), DockerComposeService { ports: vec![ - "8083:8083".to_string(), - "5432:5432".to_string(), - "1234:1234".to_string(), - "8084:8084".to_string() + DockerComposeServicePort { + target: "8083".to_string(), + published: "8083".to_string(), + ..Default::default() + }, + DockerComposeServicePort { + target: "5432".to_string(), + published: "5432".to_string(), + ..Default::default() + }, + DockerComposeServicePort { + target: "1234".to_string(), + published: "1234".to_string(), + ..Default::default() + }, + DockerComposeServicePort { + target: "8084".to_string(), + published: "8084".to_string(), + ..Default::default() + }, ], ..Default::default() }, @@ -4250,6 +4309,175 @@ chmod +x ./install.sh })) } + #[cfg(not(target_os = "windows"))] + #[gpui::test] + async fn test_spawns_devcontainer_with_plain_image(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + { + "name": "cli-${devcontainerId}", + "image": "test_image:latest", + } + "#; + + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let files = test_dependencies.fs.files(); + let uid_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile") + }) + .expect("to be found"); + let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap(); + + assert_eq!( + &uid_dockerfile, + r#"ARG BASE_IMAGE +FROM $BASE_IMAGE + +USER root + +ARG REMOTE_USER +ARG NEW_UID +ARG NEW_GID +SHELL ["/bin/sh", "-c"] +RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \ + if [ -z "$OLD_UID" ]; then \ + echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \ + elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \ + echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ + elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ + echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ + else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + FREE_GID=65532; \ + while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \ + echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \ + sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \ + fi; \ + echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ + sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ + if [ "$OLD_GID" != "$NEW_GID" ]; then \ + sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \ + fi; \ + chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \ + fi; + +ARG IMAGE_USER +USER $IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true +"# + ); + } + + #[cfg(not(target_os = "windows"))] + #[gpui::test] + async fn test_spawns_devcontainer_with_docker_compose_and_plain_image(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + { + "name": "cli-${devcontainerId}", + "dockerComposeFile": "docker-compose-plain.yml", + "service": "app", + } + "#; + + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose-plain.yml"), + r#" +services: + app: + image: test_image:latest + command: sleep infinity + volumes: + - ..:/workspace:cached + "# + .trim() + .to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let files = test_dependencies.fs.files(); + let uid_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile") + }) + .expect("to be found"); + let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap(); + + assert_eq!( + &uid_dockerfile, + r#"ARG BASE_IMAGE +FROM $BASE_IMAGE + +USER root + +ARG REMOTE_USER +ARG NEW_UID +ARG NEW_GID +SHELL ["/bin/sh", "-c"] +RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \ + if [ -z "$OLD_UID" ]; then \ + echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \ + elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \ + echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ + elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ + echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ + else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + FREE_GID=65532; \ + while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \ + echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \ + sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \ + fi; \ + echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ + sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ + if [ "$OLD_GID" != "$NEW_GID" ]; then \ + sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \ + fi; \ + chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \ + fi; + +ARG IMAGE_USER +USER $IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true +"# + ); + } + pub(crate) struct RecordedExecCommand { pub(crate) _container_id: String, pub(crate) _remote_folder: String, @@ -4372,6 +4600,24 @@ chmod +x ./install.sh state: None, }); } + if id == "test_image:latest" { + return Ok(DockerInspect { + id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc104" + .to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![HashMap::from([( + "remoteUser".to_string(), + Value::String("node".to_string()), + )])]), + }, + env: Vec::new(), + image_user: Some("root".to_string()), + }, + mounts: None, + state: None, + }); + } Err(DevContainerError::DockerNotAvailable) } @@ -4426,6 +4672,25 @@ chmod +x ./install.sh )]), })); } + if config_files.len() == 1 + && config_files.get(0) + == Some(&PathBuf::from( + "/path/to/local/project/.devcontainer/docker-compose-plain.yml", + )) + { + return Ok(Some(DockerComposeConfig { + name: None, + services: HashMap::from([( + "app".to_string(), + DockerComposeService { + image: Some("test_image:latest".to_string()), + command: vec!["sleep".to_string(), "infinity".to_string()], + ..Default::default() + }, + )]), + ..Default::default() + })); + } Err(DevContainerError::DockerNotAvailable) } async fn docker_compose_build( diff --git a/crates/dev_container/src/docker.rs b/crates/dev_container/src/docker.rs index 9594eae3d0faf67669e7d1ad487925b77a54fc34..88600e2b2a5221165b6ca80e36c0ebcfdf35013a 100644 --- a/crates/dev_container/src/docker.rs +++ b/crates/dev_container/src/docker.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, path::PathBuf}; use async_trait::async_trait; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; use util::command::Command; use crate::{ @@ -31,9 +31,10 @@ pub(crate) struct DockerInspect { pub(crate) state: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)] pub(crate) struct DockerConfigLabels { #[serde( + default, rename = "devcontainer.metadata", deserialize_with = "deserialize_metadata" )] @@ -43,6 +44,7 @@ pub(crate) struct DockerConfigLabels { #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "PascalCase")] pub(crate) struct DockerInspectConfig { + #[serde(default, deserialize_with = "deserialize_nullable_labels")] pub(crate) labels: DockerConfigLabels, #[serde(rename = "User")] pub(crate) image_user: Option, @@ -54,12 +56,11 @@ impl DockerInspectConfig { pub(crate) fn env_as_map(&self) -> Result, DevContainerError> { let mut map = HashMap::new(); for env_var in &self.env { - let parts: Vec<&str> = env_var.split("=").collect(); - if parts.len() != 2 { - log::error!("Unable to parse {env_var} into and environment key-value"); + let Some((key, value)) = env_var.split_once('=') else { + log::error!("Unable to parse {env_var} into an environment key-value"); return Err(DevContainerError::DevContainerParseFailed); - } - map.insert(parts[0].to_string(), parts[1].to_string()); + }; + map.insert(key.to_string(), value.to_string()); } Ok(map) } @@ -84,6 +85,43 @@ pub(crate) struct DockerComposeServiceBuild { pub(crate) additional_contexts: Option>, } +#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)] +pub(crate) struct DockerComposeServicePort { + #[serde(deserialize_with = "deserialize_string_or_int")] + pub(crate) target: String, + #[serde(deserialize_with = "deserialize_string_or_int")] + pub(crate) published: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) protocol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) host_ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) app_protocol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, +} + +fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrInt { + String(String), + Int(u32), + } + + match StringOrInt::deserialize(deserializer)? { + StringOrInt::String(s) => Ok(s), + StringOrInt::Int(b) => Ok(b.to_string()), + } +} + #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)] pub(crate) struct DockerComposeService { pub(crate) image: Option, @@ -93,8 +131,12 @@ pub(crate) struct DockerComposeService { pub(crate) cap_add: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) security_opt: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) labels: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "deserialize_labels" + )] + pub(crate) labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) build: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -103,9 +145,15 @@ pub(crate) struct DockerComposeService { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) env_file: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) ports: Vec, + pub(crate) ports: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) network_mode: Option, + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "deserialize_nullable_vec" + )] + pub(crate) command: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)] @@ -118,6 +166,7 @@ pub(crate) struct DockerComposeConfig { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) name: Option, pub(crate) services: HashMap, + #[serde(default)] pub(crate) volumes: HashMap, } @@ -355,6 +404,77 @@ pub(crate) trait DockerClient { fn docker_cli(&self) -> String; } +fn deserialize_labels<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + struct LabelsVisitor; + + impl<'de> de::Visitor<'de> for LabelsVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of strings or a map of string key-value pairs") + } + + fn visit_seq(self, seq: A) -> Result + where + A: de::SeqAccess<'de>, + { + let values = Vec::::deserialize(de::value::SeqAccessDeserializer::new(seq))?; + + Ok(Some( + values + .iter() + .filter_map(|v| { + let (key, value) = v.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(), + )) + } + + fn visit_map(self, map: M) -> Result + where + M: de::MapAccess<'de>, + { + HashMap::::deserialize(de::value::MapAccessDeserializer::new(map)) + .map(|v| Some(v)) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(None) + } + } + + deserializer.deserialize_any(LabelsVisitor) +} + +fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::>::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) +} + +fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) +} + fn deserialize_metadata<'de, D>( deserializer: D, ) -> Result>>, D::Error> @@ -417,11 +537,51 @@ mod test { command_json::deserialize_json_output, devcontainer_json::MountDefinition, docker::{ - Docker, DockerComposeConfig, DockerComposeService, DockerComposeVolume, DockerInspect, - DockerPs, get_remote_dir_from_config, + Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort, + DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config, }, }; + #[test] + fn should_parse_simple_env_var() { + let config = super::DockerInspectConfig { + labels: super::DockerConfigLabels { metadata: None }, + image_user: None, + env: vec!["KEY=value".to_string()], + }; + + let map = config.env_as_map().unwrap(); + assert_eq!(map.get("KEY").unwrap(), "value"); + } + + #[test] + fn should_parse_env_var_with_equals_in_value() { + let config = super::DockerInspectConfig { + labels: super::DockerConfigLabels { metadata: None }, + image_user: None, + env: vec!["COMPLEX=key=val other>=1.0".to_string()], + }; + + let map = config.env_as_map().unwrap(); + assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0"); + } + + #[test] + fn should_parse_simple_label() { + let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#; + let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap(); + let labels = service.labels.unwrap(); + assert_eq!(labels.get("com.example.key").unwrap(), "value"); + } + + #[test] + fn should_parse_label_with_equals_in_value() { + let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#; + let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap(); + let labels = service.labels.unwrap(); + assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals"); + } + #[test] fn should_create_docker_inspect_command() { let docker = Docker::new("docker"); @@ -805,6 +965,22 @@ mod test { "POSTGRES_PORT": "5432", "POSTGRES_USER": "postgres" }, + "ports": [ + { + "target": "5443", + "published": "5442" + }, + { + "name": "custom port", + "protocol": "udp", + "host_ip": "127.0.0.1", + "app_protocol": "http", + "mode": "host", + "target": "8081", + "published": "8083" + + } + ], "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm", "network_mode": "service:db", "volumes": [ @@ -860,6 +1036,7 @@ mod test { ( "app".to_string(), DockerComposeService { + command: vec!["sleep".to_string(), "infinity".to_string()], image: Some( "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(), ), @@ -869,6 +1046,23 @@ mod test { target: "/workspaces".to_string(), }], network_mode: Some("service:db".to_string()), + + ports: vec![ + DockerComposeServicePort { + target: "5443".to_string(), + published: "5442".to_string(), + ..Default::default() + }, + DockerComposeServicePort { + target: "8081".to_string(), + published: "8083".to_string(), + mode: Some("host".to_string()), + protocol: Some("udp".to_string()), + host_ip: Some("127.0.0.1".to_string()), + app_protocol: Some("http".to_string()), + name: Some("custom port".to_string()), + }, + ], ..Default::default() }, ), @@ -895,4 +1089,130 @@ mod test { assert_eq!(docker_compose_config, expected_config); } + + #[test] + fn should_deserialize_compose_labels_as_map() { + let given_config = r#" + { + "name": "devcontainer", + "services": { + "app": { + "image": "node:22-alpine", + "volumes": [], + "labels": { + "com.example.test": "value", + "another.label": "another-value" + } + } + } + } + "#; + + let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap(); + let service = config.services.get("app").unwrap(); + let labels = service.labels.clone().unwrap(); + assert_eq!( + labels, + HashMap::from([ + ("another.label".to_string(), "another-value".to_string()), + ("com.example.test".to_string(), "value".to_string()) + ]) + ); + } + + #[test] + fn should_deserialize_compose_labels_as_array() { + let given_config = r#" + { + "name": "devcontainer", + "services": { + "app": { + "image": "node:22-alpine", + "volumes": [], + "labels": ["com.example.test=value"] + } + } + } + "#; + + let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap(); + let service = config.services.get("app").unwrap(); + assert_eq!( + service.labels, + Some(HashMap::from([( + "com.example.test".to_string(), + "value".to_string() + )])) + ); + } + + #[test] + fn should_deserialize_compose_without_volumes() { + let given_config = r#" + { + "name": "devcontainer", + "services": { + "app": { + "image": "node:22-alpine", + "volumes": [] + } + } + } + "#; + + let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap(); + assert!(config.volumes.is_empty()); + } + + #[test] + fn should_deserialize_inspect_without_labels() { + let given_config = r#" + { + "Id": "sha256:abc123", + "Config": { + "Env": ["PATH=/usr/bin"], + "Cmd": ["node"], + "WorkingDir": "/" + } + } + "#; + + let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap(); + assert!(inspect.config.labels.metadata.is_none()); + assert!(inspect.config.image_user.is_none()); + } + + #[test] + fn should_deserialize_inspect_with_null_labels() { + let given_config = r#" + { + "Id": "sha256:abc123", + "Config": { + "Labels": null, + "Env": ["PATH=/usr/bin"] + } + } + "#; + + let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap(); + assert!(inspect.config.labels.metadata.is_none()); + } + + #[test] + fn should_deserialize_inspect_with_labels_but_no_metadata() { + let given_config = r#" + { + "Id": "sha256:abc123", + "Config": { + "Labels": { + "com.example.test": "value" + }, + "Env": ["PATH=/usr/bin"] + } + } + "#; + + let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap(); + assert!(inspect.config.labels.metadata.is_none()); + } } diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 75a589dea8f9c7fefe7bf13400cbdde54bf90bf1..eabb1641fd4fbec7b2f8ef0ba399a8fe9600dfa3 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -26,6 +26,7 @@ cloud_llm_client.workspace = true collections.workspace = true copilot.workspace = true copilot_ui.workspace = true +credentials_provider.workspace = true db.workspace = true edit_prediction_types.workspace = true edit_prediction_context.workspace = true @@ -65,6 +66,7 @@ uuid.workspace = true workspace.workspace = true worktree.workspace = true zed_actions.workspace = true +zed_credentials_provider.workspace = true zeta_prompt.workspace = true zstd.workspace = true diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index 5eb422246775c4409f7f15e3a672a2d407386acc..9463456132ce391b54aca8327cb6f900d81481d6 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -258,6 +258,7 @@ fn generate_timestamp_name() -> String { mod tests { use super::*; use crate::EditPredictionStore; + use client::RefreshLlmTokenListener; use client::{Client, UserStore}; use clock::FakeSystemClock; use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient}; @@ -548,7 +549,8 @@ mod tests { let http_client = FakeHttpClient::with_404_response(); let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); EditPredictionStore::global(&client, &user_store, cx); }) } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 61690c470829ca4bb16a6af9f1df2ea6e7cc6023..280427df006b510e1854ffb40cd7f995fcd9fdc6 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use client::{Client, EditPredictionUsage, UserStore}; +use client::{Client, EditPredictionUsage, NeedsLlmTokenRefresh, UserStore, global_llm_token}; use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody}; use cloud_llm_client::predict_edits_v3::{ PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse, @@ -11,6 +11,7 @@ use cloud_llm_client::{ }; use collections::{HashMap, HashSet}; use copilot::{Copilot, Reinstall, SignIn, SignOut}; +use credentials_provider::CredentialsProvider; use db::kvp::{Dismissable, KeyValueStore}; use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; @@ -30,7 +31,7 @@ use heapless::Vec as ArrayVec; use language::language_settings::all_language_settings; use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; +use language_model::LlmApiToken; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; use semver::Version; @@ -150,6 +151,7 @@ pub struct EditPredictionStore { rated_predictions: HashSet, #[cfg(test)] settled_event_callback: Option>, + credentials_provider: Arc, } pub(crate) struct EditPredictionRejectionPayload { @@ -746,7 +748,7 @@ impl EditPredictionStore { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { let data_collection_choice = Self::load_data_collection_choice(cx); - let llm_token = LlmApiToken::global(cx); + let llm_token = global_llm_token(cx); let (reject_tx, reject_rx) = mpsc::unbounded(); cx.background_spawn({ @@ -787,6 +789,8 @@ impl EditPredictionStore { .log_err(); }); + let credentials_provider = zed_credentials_provider::global(cx); + let this = Self { projects: HashMap::default(), client, @@ -807,6 +811,8 @@ impl EditPredictionStore { shown_predictions: Default::default(), #[cfg(test)] settled_event_callback: None, + + credentials_provider, }; this @@ -871,7 +877,9 @@ impl EditPredictionStore { let experiments = cx .background_spawn(async move { let http_client = client.http_client(); - let token = llm_token.acquire(&client, organization_id).await?; + let token = client + .acquire_llm_token(&llm_token, organization_id.clone()) + .await?; let url = http_client.build_zed_llm_url("/edit_prediction_experiments", &[])?; let request = http_client::Request::builder() .method(Method::GET) @@ -2315,7 +2323,10 @@ impl EditPredictionStore { zeta::request_prediction_with_zeta(self, inputs, capture_data, cx) } EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx), - EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), + EditPredictionModel::Mercury => { + self.mercury + .request_prediction(inputs, self.credentials_provider.clone(), cx) + } }; cx.spawn(async move |this, cx| { @@ -2536,12 +2547,15 @@ impl EditPredictionStore { Res: DeserializeOwned, { let http_client = client.http_client(); - let mut token = if require_auth { - Some(llm_token.acquire(&client, organization_id.clone()).await?) + Some( + client + .acquire_llm_token(&llm_token, organization_id.clone()) + .await?, + ) } else { - llm_token - .acquire(&client, organization_id.clone()) + client + .acquire_llm_token(&llm_token, organization_id.clone()) .await .ok() }; @@ -2585,7 +2599,11 @@ impl EditPredictionStore { return Ok((serde_json::from_slice(&body)?, usage)); } else if !did_retry && token.is_some() && response.needs_llm_token_refresh() { did_retry = true; - token = Some(llm_token.refresh(&client, organization_id.clone()).await?); + token = Some( + client + .refresh_llm_token(&llm_token, organization_id.clone()) + .await?, + ); } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 6fe61338e764a40aec9cf6f3191f1191bafe9200..1ba8b27aa785024a47a09c3299a1f3786a028ccf 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::udiff::apply_diff_to_string; -use client::{UserStore, test::FakeServer}; +use client::{RefreshLlmTokenListener, UserStore, test::FakeServer}; use clock::FakeSystemClock; use clock::ReplicaId; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; @@ -23,7 +23,7 @@ use language::{ Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, }; -use language_model::RefreshLlmTokenListener; + use lsp::LanguageServerId; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; @@ -2439,7 +2439,8 @@ fn init_test_with_fake_client( client.cloud_client().set_credentials(1, "test".into()); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); let ep_store = EditPredictionStore::global(&client, &user_store, cx); ( @@ -2891,7 +2892,7 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); let user_store = cx.update(|cx| cx.new(|cx| client::UserStore::new(client.clone(), cx))); cx.update(|cx| { - language_model::RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); }); let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); diff --git a/crates/edit_prediction/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs index 55635bcfd04cb6288f44907da051fa1f33d41922..88edfc306ebca21076908b3c05f7cf2837b19209 100644 --- a/crates/edit_prediction/src/license_detection.rs +++ b/crates/edit_prediction/src/license_detection.rs @@ -319,6 +319,7 @@ impl LicenseDetectionWatcher { } worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) + | worktree::Event::UpdatedRootRepoCommonDir | worktree::Event::Deleted => {} }); diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index df47a38062344512a784c6d2feb563e9848afb27..155fd449904687081da0a9eae3d4731863f02254 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -5,6 +5,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use cloud_llm_client::EditPredictionRejectReason; +use credentials_provider::CredentialsProvider; use futures::AsyncReadExt as _; use gpui::{ App, AppContext as _, Context, Entity, Global, SharedString, Task, @@ -51,10 +52,11 @@ impl Mercury { debug_tx, .. }: EditPredictionModelInput, + credentials_provider: Arc, cx: &mut Context, ) -> Task>> { self.api_token.update(cx, |key_state, cx| { - _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, credentials_provider, cx); }); let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { return Task::ready(Ok(None)); @@ -387,8 +389,9 @@ pub fn mercury_api_token(cx: &mut App) -> Entity { } pub fn load_mercury_api_token(cx: &mut App) -> Task> { + let credentials_provider = zed_credentials_provider::global(cx); mercury_api_token(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx) + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, credentials_provider, cx) }) } diff --git a/crates/edit_prediction/src/open_ai_compatible.rs b/crates/edit_prediction/src/open_ai_compatible.rs index ca378ba1fd0bc9bdbb3e85c7610e1b94c1be388f..9a11164822857d78c2fe0d9245faeb5d4f7400a0 100644 --- a/crates/edit_prediction/src/open_ai_compatible.rs +++ b/crates/edit_prediction/src/open_ai_compatible.rs @@ -42,9 +42,10 @@ pub fn open_ai_compatible_api_token(cx: &mut App) -> Entity { pub fn load_open_ai_compatible_api_token( cx: &mut App, ) -> Task> { + let credentials_provider = zed_credentials_provider::global(cx); let api_url = open_ai_compatible_api_url(cx); open_ai_compatible_api_token(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(api_url, |s| s, cx) + key_state.load_if_needed(api_url, |s| s, credentials_provider, cx) }) } diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs index 407dc4fc7239fb1974ef8bc5be4b3a99cd31f187..b2468755a8979f28635aa5e91cacf1490dc1ccd8 100644 --- a/crates/edit_prediction/src/udiff.rs +++ b/crates/edit_prediction/src/udiff.rs @@ -1,11 +1,4 @@ -use std::{ - borrow::Cow, - fmt::{Debug, Display, Write}, - mem, - ops::Range, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{mem, ops::Range, path::Path, path::PathBuf, sync::Arc}; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, hash_map::Entry}; @@ -15,6 +8,14 @@ use postage::stream::Stream as _; use project::Project; use util::{paths::PathStyle, rel_path::RelPath}; use worktree::Worktree; +use zeta_prompt::udiff::{ + DiffEvent, DiffParser, FileStatus, Hunk, disambiguate_by_line_number, find_context_candidates, +}; + +pub use zeta_prompt::udiff::{ + DiffLine, HunkLocation, apply_diff_to_string, apply_diff_to_string_with_hunk_offset, + strip_diff_metadata, strip_diff_path_prefix, +}; #[derive(Clone, Debug)] pub struct OpenedBuffers(HashMap>); @@ -189,209 +190,6 @@ pub async fn refresh_worktree_entries( Ok(()) } -pub fn strip_diff_path_prefix<'a>(diff: &'a str, prefix: &str) -> Cow<'a, str> { - if prefix.is_empty() { - return Cow::Borrowed(diff); - } - - let prefix_with_slash = format!("{}/", prefix); - let mut needs_rewrite = false; - - for line in diff.lines() { - match DiffLine::parse(line) { - DiffLine::OldPath { path } | DiffLine::NewPath { path } => { - if path.starts_with(&prefix_with_slash) { - needs_rewrite = true; - break; - } - } - _ => {} - } - } - - if !needs_rewrite { - return Cow::Borrowed(diff); - } - - let mut result = String::with_capacity(diff.len()); - for line in diff.lines() { - match DiffLine::parse(line) { - DiffLine::OldPath { path } => { - let stripped = path - .strip_prefix(&prefix_with_slash) - .unwrap_or(path.as_ref()); - result.push_str(&format!("--- a/{}\n", stripped)); - } - DiffLine::NewPath { path } => { - let stripped = path - .strip_prefix(&prefix_with_slash) - .unwrap_or(path.as_ref()); - result.push_str(&format!("+++ b/{}\n", stripped)); - } - _ => { - result.push_str(line); - result.push('\n'); - } - } - } - - Cow::Owned(result) -} -/// Strip unnecessary git metadata lines from a diff, keeping only the lines -/// needed for patch application: path headers (--- and +++), hunk headers (@@), -/// and content lines (+, -, space). -pub fn strip_diff_metadata(diff: &str) -> String { - let mut result = String::new(); - - for line in diff.lines() { - let dominated = DiffLine::parse(line); - match dominated { - // Keep path headers, hunk headers, and content lines - DiffLine::OldPath { .. } - | DiffLine::NewPath { .. } - | DiffLine::HunkHeader(_) - | DiffLine::Context(_) - | DiffLine::Deletion(_) - | DiffLine::Addition(_) - | DiffLine::NoNewlineAtEOF => { - result.push_str(line); - result.push('\n'); - } - // Skip garbage lines (diff --git, index, etc.) - DiffLine::Garbage(_) => {} - } - } - - result -} - -/// Find all byte offsets where `hunk.context` occurs as a substring of `text`. -/// -/// If no exact matches are found and the context ends with `'\n'` but `text` -/// does not, retries without the trailing newline, accepting only a match at -/// the very end of `text`. When this fallback fires, the hunk's context is -/// trimmed and its edit ranges are clamped so that downstream code doesn't -/// index past the end of the matched region. This handles diffs that are -/// missing a `\ No newline at end of file` marker: the parser always appends -/// `'\n'` via `writeln!`, so the context can have a trailing newline that -/// doesn't exist in the source text. -fn find_context_candidates(text: &str, hunk: &mut Hunk) -> Vec { - let candidates: Vec = text - .match_indices(&hunk.context) - .map(|(offset, _)| offset) - .collect(); - - if !candidates.is_empty() { - return candidates; - } - - if hunk.context.ends_with('\n') && !hunk.context.is_empty() { - let old_len = hunk.context.len(); - hunk.context.pop(); - let new_len = hunk.context.len(); - - if !hunk.context.is_empty() { - let candidates: Vec = text - .match_indices(&hunk.context) - .filter(|(offset, _)| offset + new_len == text.len()) - .map(|(offset, _)| offset) - .collect(); - - if !candidates.is_empty() { - for edit in &mut hunk.edits { - let touched_phantom = edit.range.end > new_len; - edit.range.start = edit.range.start.min(new_len); - edit.range.end = edit.range.end.min(new_len); - if touched_phantom { - // The replacement text was also written with a - // trailing '\n' that corresponds to the phantom - // newline we just removed from the context. - if edit.text.ends_with('\n') { - edit.text.pop(); - } - } - } - return candidates; - } - - // Restore if fallback didn't help either. - hunk.context.push('\n'); - debug_assert_eq!(hunk.context.len(), old_len); - } else { - hunk.context.push('\n'); - } - } - - Vec::new() -} - -/// Given multiple candidate offsets where context matches, use line numbers to disambiguate. -/// Returns the offset that matches the expected line, or None if no match or no line number available. -fn disambiguate_by_line_number( - candidates: &[usize], - expected_line: Option, - offset_to_line: &dyn Fn(usize) -> u32, -) -> Option { - match candidates.len() { - 0 => None, - 1 => Some(candidates[0]), - _ => { - let expected = expected_line?; - candidates - .iter() - .copied() - .find(|&offset| offset_to_line(offset) == expected) - } - } -} - -pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { - apply_diff_to_string_with_hunk_offset(diff_str, text).map(|(text, _)| text) -} - -/// Applies a diff to a string and returns the result along with the offset where -/// the first hunk's context matched in the original text. This offset can be used -/// to adjust cursor positions that are relative to the hunk's content. -pub fn apply_diff_to_string_with_hunk_offset( - diff_str: &str, - text: &str, -) -> Result<(String, Option)> { - let mut diff = DiffParser::new(diff_str); - - let mut text = text.to_string(); - let mut first_hunk_offset = None; - - while let Some(event) = diff.next().context("Failed to parse diff")? { - match event { - DiffEvent::Hunk { - mut hunk, - path: _, - status: _, - } => { - let candidates = find_context_candidates(&text, &mut hunk); - - let hunk_offset = - disambiguate_by_line_number(&candidates, hunk.start_line, &|offset| { - text[..offset].matches('\n').count() as u32 - }) - .ok_or_else(|| anyhow!("couldn't resolve hunk"))?; - - if first_hunk_offset.is_none() { - first_hunk_offset = Some(hunk_offset); - } - - for edit in hunk.edits.iter().rev() { - let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); - text.replace_range(range, &edit.text); - } - } - DiffEvent::FileEnd { .. } => {} - } - } - - Ok((text, first_hunk_offset)) -} - /// Returns the individual edits that would be applied by a diff to the given content. /// Each edit is a tuple of (byte_range_in_content, replacement_text). /// Uses sub-line diffing to find the precise character positions of changes. @@ -440,227 +238,6 @@ pub fn edits_for_diff(content: &str, diff_str: &str) -> Result Ok(result) } -struct PatchFile<'a> { - old_path: Cow<'a, str>, - new_path: Cow<'a, str>, -} - -struct DiffParser<'a> { - current_file: Option>, - current_line: Option<(&'a str, DiffLine<'a>)>, - hunk: Hunk, - diff: std::str::Lines<'a>, - pending_start_line: Option, - processed_no_newline: bool, - last_diff_op: LastDiffOp, -} - -#[derive(Clone, Copy, Default)] -enum LastDiffOp { - #[default] - None, - Context, - Deletion, - Addition, -} - -#[derive(Debug, PartialEq)] -enum DiffEvent<'a> { - Hunk { - path: Cow<'a, str>, - hunk: Hunk, - status: FileStatus, - }, - FileEnd { - renamed_to: Option>, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum FileStatus { - Created, - Modified, - Deleted, -} - -#[derive(Debug, Default, PartialEq)] -struct Hunk { - context: String, - edits: Vec, - start_line: Option, -} - -impl Hunk { - fn is_empty(&self) -> bool { - self.context.is_empty() && self.edits.is_empty() - } -} - -#[derive(Debug, PartialEq)] -struct Edit { - range: Range, - text: String, -} - -impl<'a> DiffParser<'a> { - fn new(diff: &'a str) -> Self { - let mut diff = diff.lines(); - let current_line = diff.next().map(|line| (line, DiffLine::parse(line))); - DiffParser { - current_file: None, - hunk: Hunk::default(), - current_line, - diff, - pending_start_line: None, - processed_no_newline: false, - last_diff_op: LastDiffOp::None, - } - } - - fn next(&mut self) -> Result>> { - loop { - let (hunk_done, file_done) = match self.current_line.as_ref().map(|e| &e.1) { - Some(DiffLine::OldPath { .. }) | Some(DiffLine::Garbage(_)) | None => (true, true), - Some(DiffLine::HunkHeader(_)) => (true, false), - _ => (false, false), - }; - - if hunk_done { - if let Some(file) = &self.current_file - && !self.hunk.is_empty() - { - let status = if file.old_path == "/dev/null" { - FileStatus::Created - } else if file.new_path == "/dev/null" { - FileStatus::Deleted - } else { - FileStatus::Modified - }; - let path = if status == FileStatus::Created { - file.new_path.clone() - } else { - file.old_path.clone() - }; - let mut hunk = mem::take(&mut self.hunk); - hunk.start_line = self.pending_start_line.take(); - self.processed_no_newline = false; - self.last_diff_op = LastDiffOp::None; - return Ok(Some(DiffEvent::Hunk { path, hunk, status })); - } - } - - if file_done { - if let Some(PatchFile { old_path, new_path }) = self.current_file.take() { - return Ok(Some(DiffEvent::FileEnd { - renamed_to: if old_path != new_path && old_path != "/dev/null" { - Some(new_path) - } else { - None - }, - })); - } - } - - let Some((line, parsed_line)) = self.current_line.take() else { - break; - }; - - util::maybe!({ - match parsed_line { - DiffLine::OldPath { path } => { - self.current_file = Some(PatchFile { - old_path: path, - new_path: "".into(), - }); - } - DiffLine::NewPath { path } => { - if let Some(current_file) = &mut self.current_file { - current_file.new_path = path - } - } - DiffLine::HunkHeader(location) => { - if let Some(loc) = location { - self.pending_start_line = Some(loc.start_line_old); - } - } - DiffLine::Context(ctx) => { - if self.current_file.is_some() { - writeln!(&mut self.hunk.context, "{ctx}")?; - self.last_diff_op = LastDiffOp::Context; - } - } - DiffLine::Deletion(del) => { - if self.current_file.is_some() { - let range = self.hunk.context.len() - ..self.hunk.context.len() + del.len() + '\n'.len_utf8(); - if let Some(last_edit) = self.hunk.edits.last_mut() - && last_edit.range.end == range.start - { - last_edit.range.end = range.end; - } else { - self.hunk.edits.push(Edit { - range, - text: String::new(), - }); - } - writeln!(&mut self.hunk.context, "{del}")?; - self.last_diff_op = LastDiffOp::Deletion; - } - } - DiffLine::Addition(add) => { - if self.current_file.is_some() { - let range = self.hunk.context.len()..self.hunk.context.len(); - if let Some(last_edit) = self.hunk.edits.last_mut() - && last_edit.range.end == range.start - { - writeln!(&mut last_edit.text, "{add}").unwrap(); - } else { - self.hunk.edits.push(Edit { - range, - text: format!("{add}\n"), - }); - } - self.last_diff_op = LastDiffOp::Addition; - } - } - DiffLine::NoNewlineAtEOF => { - if !self.processed_no_newline { - self.processed_no_newline = true; - match self.last_diff_op { - LastDiffOp::Addition => { - // Remove trailing newline from the last addition - if let Some(last_edit) = self.hunk.edits.last_mut() { - last_edit.text.pop(); - } - } - LastDiffOp::Deletion => { - // Remove trailing newline from context (which includes the deletion) - self.hunk.context.pop(); - if let Some(last_edit) = self.hunk.edits.last_mut() { - last_edit.range.end -= 1; - } - } - LastDiffOp::Context | LastDiffOp::None => { - // Remove trailing newline from context - self.hunk.context.pop(); - } - } - } - } - DiffLine::Garbage(_) => {} - } - - anyhow::Ok(()) - }) - .with_context(|| format!("on line:\n\n```\n{}```", line))?; - - self.current_line = self.diff.next().map(|line| (line, DiffLine::parse(line))); - } - - anyhow::Ok(None) - } -} - fn resolve_hunk_edits_in_buffer( mut hunk: Hunk, buffer: &TextBufferSnapshot, @@ -713,144 +290,6 @@ fn resolve_hunk_edits_in_buffer( Ok(iter) } -#[derive(Debug, PartialEq)] -pub enum DiffLine<'a> { - OldPath { path: Cow<'a, str> }, - NewPath { path: Cow<'a, str> }, - HunkHeader(Option), - Context(&'a str), - Deletion(&'a str), - Addition(&'a str), - NoNewlineAtEOF, - Garbage(&'a str), -} - -#[derive(Debug, PartialEq)] -pub struct HunkLocation { - pub start_line_old: u32, - count_old: u32, - pub start_line_new: u32, - count_new: u32, -} - -impl<'a> DiffLine<'a> { - pub fn parse(line: &'a str) -> Self { - Self::try_parse(line).unwrap_or(Self::Garbage(line)) - } - - fn try_parse(line: &'a str) -> Option { - if line.starts_with("\\ No newline") { - return Some(Self::NoNewlineAtEOF); - } - if let Some(header) = line.strip_prefix("---").and_then(eat_required_whitespace) { - let path = parse_header_path("a/", header); - Some(Self::OldPath { path }) - } else if let Some(header) = line.strip_prefix("+++").and_then(eat_required_whitespace) { - Some(Self::NewPath { - path: parse_header_path("b/", header), - }) - } else if let Some(header) = line.strip_prefix("@@").and_then(eat_required_whitespace) { - if header.starts_with("...") { - return Some(Self::HunkHeader(None)); - } - - let mut tokens = header.split_whitespace(); - let old_range = tokens.next()?.strip_prefix('-')?; - let new_range = tokens.next()?.strip_prefix('+')?; - - let (start_line_old, count_old) = old_range.split_once(',').unwrap_or((old_range, "1")); - let (start_line_new, count_new) = new_range.split_once(',').unwrap_or((new_range, "1")); - - Some(Self::HunkHeader(Some(HunkLocation { - start_line_old: start_line_old.parse::().ok()?.saturating_sub(1), - count_old: count_old.parse().ok()?, - start_line_new: start_line_new.parse::().ok()?.saturating_sub(1), - count_new: count_new.parse().ok()?, - }))) - } else if let Some(deleted_header) = line.strip_prefix("-") { - Some(Self::Deletion(deleted_header)) - } else if line.is_empty() { - Some(Self::Context("")) - } else if let Some(context) = line.strip_prefix(" ") { - Some(Self::Context(context)) - } else { - Some(Self::Addition(line.strip_prefix("+")?)) - } - } -} - -impl<'a> Display for DiffLine<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DiffLine::OldPath { path } => write!(f, "--- {path}"), - DiffLine::NewPath { path } => write!(f, "+++ {path}"), - DiffLine::HunkHeader(Some(hunk_location)) => { - write!( - f, - "@@ -{},{} +{},{} @@", - hunk_location.start_line_old + 1, - hunk_location.count_old, - hunk_location.start_line_new + 1, - hunk_location.count_new - ) - } - DiffLine::HunkHeader(None) => write!(f, "@@ ... @@"), - DiffLine::Context(content) => write!(f, " {content}"), - DiffLine::Deletion(content) => write!(f, "-{content}"), - DiffLine::Addition(content) => write!(f, "+{content}"), - DiffLine::NoNewlineAtEOF => write!(f, "\\ No newline at end of file"), - DiffLine::Garbage(line) => write!(f, "{line}"), - } - } -} - -fn parse_header_path<'a>(strip_prefix: &'static str, header: &'a str) -> Cow<'a, str> { - if !header.contains(['"', '\\']) { - let path = header.split_ascii_whitespace().next().unwrap_or(header); - return Cow::Borrowed(path.strip_prefix(strip_prefix).unwrap_or(path)); - } - - let mut path = String::with_capacity(header.len()); - let mut in_quote = false; - let mut chars = header.chars().peekable(); - let mut strip_prefix = Some(strip_prefix); - - while let Some(char) = chars.next() { - if char == '"' { - in_quote = !in_quote; - } else if char == '\\' { - let Some(&next_char) = chars.peek() else { - break; - }; - chars.next(); - path.push(next_char); - } else if char.is_ascii_whitespace() && !in_quote { - break; - } else { - path.push(char); - } - - if let Some(prefix) = strip_prefix - && path == prefix - { - strip_prefix.take(); - path.clear(); - } - } - - Cow::Owned(path) -} - -fn eat_required_whitespace(header: &str) -> Option<&str> { - let trimmed = header.trim_ascii_start(); - - if trimmed.len() == header.len() { - None - } else { - Some(trimmed) - } -} - #[cfg(test)] mod tests { use super::*; @@ -862,387 +301,6 @@ mod tests { use settings::SettingsStore; use util::path; - #[test] - fn parse_lines_simple() { - let input = indoc! {" - diff --git a/text.txt b/text.txt - index 86c770d..a1fd855 100644 - --- a/file.txt - +++ b/file.txt - @@ -1,2 +1,3 @@ - context - -deleted - +inserted - garbage - - --- b/file.txt - +++ a/file.txt - "}; - - let lines = input.lines().map(DiffLine::parse).collect::>(); - - pretty_assertions::assert_eq!( - lines, - &[ - DiffLine::Garbage("diff --git a/text.txt b/text.txt"), - DiffLine::Garbage("index 86c770d..a1fd855 100644"), - DiffLine::OldPath { - path: "file.txt".into() - }, - DiffLine::NewPath { - path: "file.txt".into() - }, - DiffLine::HunkHeader(Some(HunkLocation { - start_line_old: 0, - count_old: 2, - start_line_new: 0, - count_new: 3 - })), - DiffLine::Context("context"), - DiffLine::Deletion("deleted"), - DiffLine::Addition("inserted"), - DiffLine::Garbage("garbage"), - DiffLine::Context(""), - DiffLine::OldPath { - path: "b/file.txt".into() - }, - DiffLine::NewPath { - path: "a/file.txt".into() - }, - ] - ); - } - - #[test] - fn file_header_extra_space() { - let options = ["--- file", "--- file", "---\tfile"]; - - for option in options { - pretty_assertions::assert_eq!( - DiffLine::parse(option), - DiffLine::OldPath { - path: "file".into() - }, - "{option}", - ); - } - } - - #[test] - fn hunk_header_extra_space() { - let options = [ - "@@ -1,2 +1,3 @@", - "@@ -1,2 +1,3 @@", - "@@\t-1,2\t+1,3\t@@", - "@@ -1,2 +1,3 @@", - "@@ -1,2 +1,3 @@", - "@@ -1,2 +1,3 @@", - "@@ -1,2 +1,3 @@ garbage", - ]; - - for option in options { - pretty_assertions::assert_eq!( - DiffLine::parse(option), - DiffLine::HunkHeader(Some(HunkLocation { - start_line_old: 0, - count_old: 2, - start_line_new: 0, - count_new: 3 - })), - "{option}", - ); - } - } - - #[test] - fn hunk_header_without_location() { - pretty_assertions::assert_eq!(DiffLine::parse("@@ ... @@"), DiffLine::HunkHeader(None)); - } - - #[test] - fn test_parse_path() { - assert_eq!(parse_header_path("a/", "foo.txt"), "foo.txt"); - assert_eq!( - parse_header_path("a/", "foo/bar/baz.txt"), - "foo/bar/baz.txt" - ); - assert_eq!(parse_header_path("a/", "a/foo.txt"), "foo.txt"); - assert_eq!( - parse_header_path("a/", "a/foo/bar/baz.txt"), - "foo/bar/baz.txt" - ); - - // Extra - assert_eq!( - parse_header_path("a/", "a/foo/bar/baz.txt 2025"), - "foo/bar/baz.txt" - ); - assert_eq!( - parse_header_path("a/", "a/foo/bar/baz.txt\t2025"), - "foo/bar/baz.txt" - ); - assert_eq!( - parse_header_path("a/", "a/foo/bar/baz.txt \""), - "foo/bar/baz.txt" - ); - - // Quoted - assert_eq!( - parse_header_path("a/", "a/foo/bar/\"baz quox.txt\""), - "foo/bar/baz quox.txt" - ); - assert_eq!( - parse_header_path("a/", "\"a/foo/bar/baz quox.txt\""), - "foo/bar/baz quox.txt" - ); - assert_eq!( - parse_header_path("a/", "\"foo/bar/baz quox.txt\""), - "foo/bar/baz quox.txt" - ); - assert_eq!(parse_header_path("a/", "\"whatever 🤷\""), "whatever 🤷"); - assert_eq!( - parse_header_path("a/", "\"foo/bar/baz quox.txt\" 2025"), - "foo/bar/baz quox.txt" - ); - // unescaped quotes are dropped - assert_eq!(parse_header_path("a/", "foo/\"bar\""), "foo/bar"); - - // Escaped - assert_eq!( - parse_header_path("a/", "\"foo/\\\"bar\\\"/baz.txt\""), - "foo/\"bar\"/baz.txt" - ); - assert_eq!( - parse_header_path("a/", "\"C:\\\\Projects\\\\My App\\\\old file.txt\""), - "C:\\Projects\\My App\\old file.txt" - ); - } - - #[test] - fn test_parse_diff_with_leading_and_trailing_garbage() { - let diff = indoc! {" - I need to make some changes. - - I'll change the following things: - - one - - two - - three - - ``` - --- a/file.txt - +++ b/file.txt - one - +AND - two - ``` - - Summary of what I did: - - one - - two - - three - - That's about it. - "}; - - let mut events = Vec::new(); - let mut parser = DiffParser::new(diff); - while let Some(event) = parser.next().unwrap() { - events.push(event); - } - - assert_eq!( - events, - &[ - DiffEvent::Hunk { - path: "file.txt".into(), - hunk: Hunk { - context: "one\ntwo\n".into(), - edits: vec![Edit { - range: 4..4, - text: "AND\n".into() - }], - start_line: None, - }, - status: FileStatus::Modified, - }, - DiffEvent::FileEnd { renamed_to: None } - ], - ) - } - - #[test] - fn test_no_newline_at_eof() { - let diff = indoc! {" - --- a/file.py - +++ b/file.py - @@ -55,7 +55,3 @@ class CustomDataset(Dataset): - torch.set_rng_state(state) - mask = self.transform(mask) - - - if self.mode == 'Training': - - return (img, mask, name) - - else: - - return (img, mask, name) - \\ No newline at end of file - "}; - - let mut events = Vec::new(); - let mut parser = DiffParser::new(diff); - while let Some(event) = parser.next().unwrap() { - events.push(event); - } - - assert_eq!( - events, - &[ - DiffEvent::Hunk { - path: "file.py".into(), - hunk: Hunk { - context: concat!( - " torch.set_rng_state(state)\n", - " mask = self.transform(mask)\n", - "\n", - " if self.mode == 'Training':\n", - " return (img, mask, name)\n", - " else:\n", - " return (img, mask, name)", - ) - .into(), - edits: vec![Edit { - range: 80..203, - text: "".into() - }], - start_line: Some(54), // @@ -55,7 -> line 54 (0-indexed) - }, - status: FileStatus::Modified, - }, - DiffEvent::FileEnd { renamed_to: None } - ], - ); - } - - #[test] - fn test_no_newline_at_eof_addition() { - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,2 +1,3 @@ - context - -deleted - +added line - \\ No newline at end of file - "}; - - let mut events = Vec::new(); - let mut parser = DiffParser::new(diff); - while let Some(event) = parser.next().unwrap() { - events.push(event); - } - - assert_eq!( - events, - &[ - DiffEvent::Hunk { - path: "file.txt".into(), - hunk: Hunk { - context: "context\ndeleted\n".into(), - edits: vec![Edit { - range: 8..16, - text: "added line".into() - }], - start_line: Some(0), // @@ -1,2 -> line 0 (0-indexed) - }, - status: FileStatus::Modified, - }, - DiffEvent::FileEnd { renamed_to: None } - ], - ); - } - - #[test] - fn test_double_no_newline_at_eof() { - // Two consecutive "no newline" markers - the second should be ignored - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,3 @@ - line1 - -old - +new - line3 - \\ No newline at end of file - \\ No newline at end of file - "}; - - let mut events = Vec::new(); - let mut parser = DiffParser::new(diff); - while let Some(event) = parser.next().unwrap() { - events.push(event); - } - - assert_eq!( - events, - &[ - DiffEvent::Hunk { - path: "file.txt".into(), - hunk: Hunk { - context: "line1\nold\nline3".into(), // Only one newline removed - edits: vec![Edit { - range: 6..10, // "old\n" is 4 bytes - text: "new\n".into() - }], - start_line: Some(0), - }, - status: FileStatus::Modified, - }, - DiffEvent::FileEnd { renamed_to: None } - ], - ); - } - - #[test] - fn test_no_newline_after_context_not_addition() { - // "No newline" after context lines should remove newline from context, - // not from an earlier addition - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,4 +1,4 @@ - line1 - -old - +new - line3 - line4 - \\ No newline at end of file - "}; - - let mut events = Vec::new(); - let mut parser = DiffParser::new(diff); - while let Some(event) = parser.next().unwrap() { - events.push(event); - } - - assert_eq!( - events, - &[ - DiffEvent::Hunk { - path: "file.txt".into(), - hunk: Hunk { - // newline removed from line4 (context), not from "new" (addition) - context: "line1\nold\nline3\nline4".into(), - edits: vec![Edit { - range: 6..10, // "old\n" is 4 bytes - text: "new\n".into() // Still has newline - }], - start_line: Some(0), - }, - status: FileStatus::Modified, - }, - DiffEvent::FileEnd { renamed_to: None } - ], - ); - } - #[test] fn test_line_number_disambiguation() { // Test that line numbers from hunk headers are used to disambiguate @@ -1535,197 +593,6 @@ mod tests { assert_eq!(cursor_column, " let x = ".len()); } - #[test] - fn test_strip_diff_metadata() { - let diff_with_metadata = indoc! {r#" - diff --git a/file.txt b/file.txt - index 1234567..abcdefg 100644 - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,4 @@ - context line - -removed line - +added line - more context - "#}; - - let stripped = strip_diff_metadata(diff_with_metadata); - - assert_eq!( - stripped, - indoc! {r#" - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,4 @@ - context line - -removed line - +added line - more context - "#} - ); - } - - #[test] - fn test_apply_diff_to_string_no_trailing_newline() { - // Text without trailing newline; diff generated without - // `\ No newline at end of file` marker. - let text = "line1\nline2\nline3"; - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,3 @@ - line1 - -line2 - +replaced - line3 - "}; - - let result = apply_diff_to_string(diff, text).unwrap(); - assert_eq!(result, "line1\nreplaced\nline3"); - } - - #[test] - fn test_apply_diff_to_string_trailing_newline_present() { - // When text has a trailing newline, exact matching still works and - // the fallback is never needed. - let text = "line1\nline2\nline3\n"; - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,3 @@ - line1 - -line2 - +replaced - line3 - "}; - - let result = apply_diff_to_string(diff, text).unwrap(); - assert_eq!(result, "line1\nreplaced\nline3\n"); - } - - #[test] - fn test_apply_diff_to_string_deletion_at_end_no_trailing_newline() { - // Deletion of the last line when text has no trailing newline. - // The edit range must be clamped so it doesn't index past the - // end of the text. - let text = "line1\nline2\nline3"; - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,2 @@ - line1 - line2 - -line3 - "}; - - let result = apply_diff_to_string(diff, text).unwrap(); - assert_eq!(result, "line1\nline2\n"); - } - - #[test] - fn test_apply_diff_to_string_replace_last_line_no_trailing_newline() { - // Replace the last line when text has no trailing newline. - let text = "aaa\nbbb\nccc"; - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,3 +1,3 @@ - aaa - bbb - -ccc - +ddd - "}; - - let result = apply_diff_to_string(diff, text).unwrap(); - assert_eq!(result, "aaa\nbbb\nddd"); - } - - #[test] - fn test_apply_diff_to_string_multibyte_no_trailing_newline() { - // Multi-byte UTF-8 characters near the end; ensures char boundary - // safety when the fallback clamps edit ranges. - let text = "hello\n세계"; - let diff = indoc! {" - --- a/file.txt - +++ b/file.txt - @@ -1,2 +1,2 @@ - hello - -세계 - +world - "}; - - let result = apply_diff_to_string(diff, text).unwrap(); - assert_eq!(result, "hello\nworld"); - } - - #[test] - fn test_find_context_candidates_no_false_positive_mid_text() { - // The stripped fallback must only match at the end of text, not in - // the middle where a real newline exists. - let text = "aaa\nbbb\nccc\n"; - let mut hunk = Hunk { - context: "bbb\n".into(), - edits: vec![], - start_line: None, - }; - - let candidates = find_context_candidates(text, &mut hunk); - // Exact match at offset 4 — the fallback is not used. - assert_eq!(candidates, vec![4]); - } - - #[test] - fn test_find_context_candidates_fallback_at_end() { - let text = "aaa\nbbb"; - let mut hunk = Hunk { - context: "bbb\n".into(), - edits: vec![], - start_line: None, - }; - - let candidates = find_context_candidates(text, &mut hunk); - assert_eq!(candidates, vec![4]); - // Context should be stripped. - assert_eq!(hunk.context, "bbb"); - } - - #[test] - fn test_find_context_candidates_no_fallback_mid_text() { - // "bbb" appears mid-text followed by a newline, so the exact - // match succeeds. Verify the stripped fallback doesn't produce a - // second, spurious candidate. - let text = "aaa\nbbb\nccc"; - let mut hunk = Hunk { - context: "bbb\nccc\n".into(), - edits: vec![], - start_line: None, - }; - - let candidates = find_context_candidates(text, &mut hunk); - // No exact match (text ends without newline after "ccc"), but the - // stripped context "bbb\nccc" matches at offset 4, which is the end. - assert_eq!(candidates, vec![4]); - assert_eq!(hunk.context, "bbb\nccc"); - } - - #[test] - fn test_find_context_candidates_clamps_edit_ranges() { - let text = "aaa\nbbb"; - let mut hunk = Hunk { - context: "aaa\nbbb\n".into(), - edits: vec![Edit { - range: 4..8, // "bbb\n" — end points at the trailing \n - text: "ccc\n".into(), - }], - start_line: None, - }; - - let candidates = find_context_candidates(text, &mut hunk); - assert_eq!(candidates, vec![0]); - // Edit range end should be clamped to 7 (new context length). - assert_eq!(hunk.edits[0].range, 4..7); - } - #[test] fn test_edits_for_diff_no_trailing_newline() { let content = "foo\nbar\nbaz"; diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index 2a1b49007bd19e721a6d95ebddda3758c86aaaef..24a6f1acd470fb8ee77e87d993079298f45b390c 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -6,11 +6,11 @@ use crate::{ retrieve_context::run_context_retrieval, }; use anyhow::{Context as _, Result, anyhow}; -use edit_prediction::udiff; use gpui::AsyncApp; use similar::DiffableStr; use std::ops::Range; use std::sync::Arc; +use zeta_prompt::udiff; use zeta_prompt::{ ZetaFormat, encode_patch_as_output_for_format, excerpt_range_for_format, format_zeta_prompt, multi_region, output_end_marker_for_format, resolve_cursor_region, diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index 3a204a7052f8a41d6e7c2c49860b62f588358644..48b7381020f48d868d9f6413ef343b30718e5be6 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -1,4 +1,4 @@ -use client::{Client, ProxySettings, UserStore}; +use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore}; use db::AppDatabase; use extension::ExtensionHostProxy; use fs::RealFs; @@ -109,7 +109,8 @@ pub fn init(cx: &mut App) -> EpAppState { debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store.clone(), client.clone(), cx); languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); prompt_store::init(cx); diff --git a/crates/edit_prediction_cli/src/reversal_tracking.rs b/crates/edit_prediction_cli/src/reversal_tracking.rs index 60661cea04beae4aba4713ac86b51fab42c91979..34ddfd5f5ec0edca2b5de64a6f033a6463dcc133 100644 --- a/crates/edit_prediction_cli/src/reversal_tracking.rs +++ b/crates/edit_prediction_cli/src/reversal_tracking.rs @@ -2,8 +2,8 @@ use std::ops::Range; use std::path::Path; use std::sync::Arc; -use edit_prediction::udiff::apply_diff_to_string; use language::{char_diff, text_diff}; +use zeta_prompt::udiff::apply_diff_to_string; use zeta_prompt::ZetaPromptInput; @@ -653,9 +653,9 @@ pub fn compute_prediction_reversal_ratio( #[cfg(test)] mod tests { use super::*; - use edit_prediction::udiff::apply_diff_to_string; use indoc::indoc; use zeta_prompt::ExcerptRanges; + use zeta_prompt::udiff::apply_diff_to_string; fn make_test_prompt_inputs( content: &str, diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index cb1bd472c3e4268fe0e1037e331ed8cbd0b51cfb..1dace832d4998362610e860b386f4db49f965144 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -10,13 +10,13 @@ use crate::{ reversal_tracking, }; use anyhow::Context as _; -use edit_prediction::udiff::{apply_diff_to_string, apply_diff_to_string_with_hunk_offset}; use gpui::AsyncApp; use serde::Serialize; use std::fs::File; use std::io::BufWriter; use std::path::Path; use std::sync::Arc; +use zeta_prompt::udiff::{apply_diff_to_string, apply_diff_to_string_with_hunk_offset}; pub async fn run_scoring( example: &mut Example, diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 25874457a8e3d4787de22e3e8c0e2c61a49708f8..67318e3300e73085fe40c2e22edfcd06778902c8 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -2043,6 +2043,7 @@ impl BlockMapWriter<'_> { multi_buffer: &MultiBuffer, cx: &App, ) { + let multi_buffer_snapshot = multi_buffer.snapshot(cx); let mut ranges = Vec::new(); let mut companion_buffer_ids = HashSet::default(); for buffer_id in buffer_ids { @@ -2051,7 +2052,7 @@ impl BlockMapWriter<'_> { } else { self.block_map.folded_buffers.remove(&buffer_id); } - ranges.extend(multi_buffer.range_for_buffer(buffer_id, cx)); + ranges.extend(multi_buffer_snapshot.range_for_buffer(buffer_id)); if let Some(companion) = &self.companion && companion.inverse.is_some() { diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index d1e326bc93b8052f3ae089c211e65eb3ef020fdf..987801471e5602f256ce2dd65edd57873c878027 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -1081,6 +1081,44 @@ async fn test_cancel_clears_stale_edit_prediction_in_menu(cx: &mut gpui::TestApp }); } +#[gpui::test] +async fn test_discard_clears_delegate_completion(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + load_default_keymap(cx); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.set_state("let x = ˇ;"); + + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + + cx.update_editor(|editor, _window, _cx| { + assert!(editor.active_edit_prediction.is_some()); + }); + + // Dismiss the prediction — this must call discard() on the delegate, + // which should clear self.completion. + cx.simulate_keystroke("escape"); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert!(editor.active_edit_prediction.is_none()); + }); + + // update_visible_edit_prediction must NOT bring the prediction back, + // because discard() cleared self.completion in the delegate. + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + + cx.update_editor(|editor, _window, _cx| { + assert!( + editor.active_edit_prediction.is_none(), + "prediction must not resurface after discard()" + ); + }); +} + fn accept_completion(cx: &mut EditorTestContext) { cx.update_editor(|editor, window, cx| { editor.accept_edit_prediction(&crate::AcceptEditPrediction, window, cx) @@ -1350,6 +1388,7 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate { _reason: edit_prediction_types::EditPredictionDiscardReason, _cx: &mut gpui::Context, ) { + self.completion.take(); } fn suggest<'a>( @@ -1426,6 +1465,7 @@ impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate { _reason: edit_prediction_types::EditPredictionDiscardReason, _cx: &mut gpui::Context, ) { + self.completion.take(); } fn suggest<'a>( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e4cccf3fc5607937a2a82b2ab1089e00bbda6fa7..6550d79c9f73799d37ccf6433db38f2719636ee6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11741,10 +11741,9 @@ impl Editor { buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) } - let buffer = self.buffer().read(cx); let ranges = buffer_ids .into_iter() - .flat_map(|buffer_id| buffer.range_for_buffer(buffer_id, cx)) + .flat_map(|buffer_id| snapshot.range_for_buffer(buffer_id)) .collect::>(); self.restore_hunks_in_ranges(ranges, window, cx); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 2afd724f5e4a7332b713e14f1e4da5ad32517f13..c29df272d35af5a69ba07c76cb7da3866786bd2b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -52,7 +52,7 @@ use settings::{ ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent, SettingsContent, SettingsStore, }; -use std::borrow::Cow; +use std::{borrow::Cow, sync::Arc}; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ iter, @@ -19112,7 +19112,7 @@ async fn test_copy_highlight_json(cx: &mut TestAppContext) { let x = 1;ˇ } "}); - setup_rust_syntax_highlighting(&mut cx); + setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.copy_highlight_json(&CopyHighlightJson, window, cx); @@ -19160,7 +19160,7 @@ async fn test_copy_highlight_json_selected_range(cx: &mut TestAppContext) { let yˇ» = 2; } "}); - setup_rust_syntax_highlighting(&mut cx); + setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.copy_highlight_json(&CopyHighlightJson, window, cx); @@ -19203,7 +19203,7 @@ async fn test_copy_highlight_json_selected_line_range(cx: &mut TestAppContext) { let yˇ» = 2; } "}); - setup_rust_syntax_highlighting(&mut cx); + setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.selections.set_line_mode(true); @@ -19253,7 +19253,7 @@ async fn test_copy_highlight_json_single_line(cx: &mut TestAppContext) { let y = 2; } "}); - setup_rust_syntax_highlighting(&mut cx); + setup_syntax_highlighting(rust_lang(), &mut cx); cx.update_editor(|editor, window, cx| { editor.selections.set_line_mode(true); @@ -19280,34 +19280,6 @@ async fn test_copy_highlight_json_single_line(cx: &mut TestAppContext) { ); } -fn setup_rust_syntax_highlighting(cx: &mut EditorTestContext) { - let syntax = SyntaxTheme::new_test(vec![ - ("keyword", Hsla::red()), - ("function", Hsla::blue()), - ("variable", Hsla::green()), - ("number", Hsla::default()), - ("operator", Hsla::default()), - ("punctuation.bracket", Hsla::default()), - ("punctuation.delimiter", Hsla::default()), - ]); - - let language = rust_lang(); - language.set_theme(&syntax); - - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.executor().run_until_parked(); - cx.update_editor(|editor, window, cx| { - editor.set_style( - EditorStyle { - syntax: Arc::new(syntax), - ..Default::default() - }, - window, - cx, - ); - }); -} - #[gpui::test] async fn test_following(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -35729,3 +35701,75 @@ async fn test_align_selections_multicolumn(cx: &mut TestAppContext) { cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); cx.assert_editor_state(after); } + +#[gpui::test] +async fn test_custom_fallback_highlights(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {"fn main(self, variable: TType) {ˇ}"}); + + let variable_color = Hsla::green(); + let function_color = Hsla::blue(); + + let test_cases = [ + ("@variable", Some(variable_color)), + ("@type", None), + ("@type @variable", Some(variable_color)), + ("@variable @type", Some(variable_color)), + ("@variable @function", Some(function_color)), + ("@function @variable", Some(variable_color)), + ]; + + for (test_case, expected) in test_cases { + let custom_rust_lang = Arc::into_inner(rust_lang()) + .unwrap() + .with_highlights_query(format! {r#"(type_identifier) {test_case}"#}.as_str()) + .unwrap(); + let theme = setup_syntax_highlighting(Arc::new(custom_rust_lang), &mut cx); + let expected = expected.map_or_else(Vec::new, |expected_color| { + vec![(24..29, HighlightStyle::color(expected_color))] + }); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + expected, + snapshot.combined_highlights(MultiBufferOffset(0)..snapshot.buffer().len(), &theme), + "Test case with '{test_case}' highlights query did not pass", + ); + }); + } +} + +fn setup_syntax_highlighting( + language: Arc, + cx: &mut EditorTestContext, +) -> Arc { + let syntax = Arc::new(SyntaxTheme::new_test(vec![ + ("keyword", Hsla::red()), + ("function", Hsla::blue()), + ("variable", Hsla::green()), + ("number", Hsla::default()), + ("operator", Hsla::default()), + ("punctuation.bracket", Hsla::default()), + ("punctuation.delimiter", Hsla::default()), + ])); + + language.set_theme(&syntax); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.set_style( + EditorStyle { + syntax: syntax.clone(), + ..EditorStyle::default() + }, + window, + cx, + ); + }); + + syntax +} diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 7f05f4355bfaa218dbc26aab77d949b2146816d7..e00fd20ed5abdcd49dbe87510bfd8de54b60fce2 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1166,7 +1166,7 @@ mod tests { }); cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key()); cx.background_executor.run_until_parked(); - assert!(requests.try_next().is_err()); + assert!(requests.try_recv().is_err()); cx.assert_editor_text_highlights( HighlightKey::HoveredLinkState, indoc! {" diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 28e920c28bd9854a38a5019622248fa79cd0a8e1..d2c157014330cc26f0024ace87ee0e3688f85eaa 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1630,6 +1630,7 @@ impl SearchableItem for Editor { regex: true, replacement: false, selection: false, + select_all: true, find_in_results: true, } } else { @@ -1639,6 +1640,7 @@ impl SearchableItem for Editor { regex: true, replacement: true, selection: true, + select_all: true, find_in_results: false, } } diff --git a/crates/env_var/Cargo.toml b/crates/env_var/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2cbbd08c7833d3e57a09766d42ffffe35c620a93 --- /dev/null +++ b/crates/env_var/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "env_var" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/env_var.rs" + +[dependencies] +gpui.workspace = true diff --git a/crates/env_var/LICENSE-GPL b/crates/env_var/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/env_var/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/env_var/src/env_var.rs b/crates/env_var/src/env_var.rs new file mode 100644 index 0000000000000000000000000000000000000000..79f671e0147ebfaad4ab76a123cc477dc7e55cb7 --- /dev/null +++ b/crates/env_var/src/env_var.rs @@ -0,0 +1,40 @@ +use gpui::SharedString; + +#[derive(Clone)] +pub struct EnvVar { + pub name: SharedString, + /// Value of the environment variable. Also `None` when set to an empty string. + pub value: Option, +} + +impl EnvVar { + pub fn new(name: SharedString) -> Self { + let value = std::env::var(name.as_str()).ok(); + if value.as_ref().is_some_and(|v| v.is_empty()) { + Self { name, value: None } + } else { + Self { name, value } + } + } + + pub fn or(self, other: EnvVar) -> EnvVar { + if self.value.is_some() { self } else { other } + } +} + +/// Creates a `LazyLock` expression for use in a `static` declaration. +#[macro_export] +macro_rules! env_var { + ($name:expr) => { + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) + }; +} + +/// Generates a `LazyLock` expression for use in a `static` declaration. Checks if the +/// environment variable exists and is non-empty. +#[macro_export] +macro_rules! bool_env_var { + ($name:expr) => { + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) + }; +} diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 72feaacbae270224240f1da9e6e6c1008ba97c84..0ddd99e8f8abd9dbd73e1d7461526f3e7cb24f11 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; -use client::{Client, ProxySettings, UserStore}; +use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore}; use db::AppDatabase; use extension::ExtensionHostProxy; use fs::RealFs; @@ -108,7 +108,8 @@ pub fn init(cx: &mut App) -> Arc { let extension_host_proxy = ExtensionHostProxy::global(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store.clone(), client.clone(), cx); languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); prompt_store::init(cx); diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 1c204398c34728cab6b05687050243b4a988902c..f0e789994127c9347c8eb6b8d16417ba7eaaf831 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -296,16 +296,12 @@ impl ExtensionBuilder { let remotes_output = util::command::new_command("git") .arg("--git-dir") .arg(&git_dir) - .args(["remote", "-v"]) + .args(["remote", "get-url", "origin"]) + .env("GIT_CONFIG_GLOBAL", "/dev/null") .output() .await?; let has_remote = remotes_output.status.success() - && String::from_utf8_lossy(&remotes_output.stdout) - .lines() - .any(|line| { - let mut parts = line.split(|c: char| c.is_whitespace()); - parts.next() == Some("origin") && parts.any(|part| part == url) - }); + && String::from_utf8_lossy(&remotes_output.stdout).trim() == url; if !has_remote { bail!( "grammar directory '{}' already exists, but is not a git clone of '{}'", diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 4d477aa4b393ee8b04829833324cd9092c2a04cd..54dc96ad37f8e51a1074a0a32976f8236cb1a0ed 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -47,12 +47,6 @@ impl FeatureFlag for DiffReviewFeatureFlag { } } -pub struct GitGraphFeatureFlag; - -impl FeatureFlag for GitGraphFeatureFlag { - const NAME: &'static str = "git-graph"; -} - pub struct StreamingEditFileToolFeatureFlag; impl FeatureFlag for StreamingEditFileToolFeatureFlag { diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index fc66e27fc9a32c2a8897eb5c9faee917c21177c5..c25b0ded5daea0674629ce4bea00736cb2eb3ffb 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -10,6 +10,7 @@ use git::{ GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, + stash::GitStash, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, UnmergedStatus, @@ -53,6 +54,7 @@ pub struct FakeGitRepositoryState { pub simulated_create_worktree_error: Option, pub refs: HashMap, pub graph_commits: Vec>, + pub stash_entries: GitStash, } impl FakeGitRepositoryState { @@ -72,6 +74,7 @@ impl FakeGitRepositoryState { oids: Default::default(), remotes: HashMap::default(), graph_commits: Vec::new(), + stash_entries: Default::default(), } } } @@ -378,13 +381,13 @@ impl GitRepository for FakeGitRepository { } fn stash_entries(&self) -> BoxFuture<'_, Result> { - async { Ok(git::stash::GitStash::default()) }.boxed() + self.with_state_async(false, |state| Ok(state.stash_entries.clone())) } fn branches(&self) -> BoxFuture<'_, Result>> { self.with_state_async(false, move |state| { let current_branch = &state.current_branch_name; - Ok(state + let mut branches = state .branches .iter() .map(|branch_name| { @@ -402,7 +405,11 @@ impl GitRepository for FakeGitRepository { upstream: None, } }) - .collect()) + .collect::>(); + // compute snapshot expects these to be sorted by ref_name + // because that's what git itself does + branches.sort_by(|a, b| a.ref_name.cmp(&b.ref_name)); + Ok(branches) }) } @@ -1053,10 +1060,88 @@ impl GitRepository for FakeGitRepository { fn diff_checkpoints( &self, - _base_checkpoint: GitRepositoryCheckpoint, - _target_checkpoint: GitRepositoryCheckpoint, + base_checkpoint: GitRepositoryCheckpoint, + target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { - unimplemented!() + let executor = self.executor.clone(); + let checkpoints = self.checkpoints.clone(); + async move { + executor.simulate_random_delay().await; + let checkpoints = checkpoints.lock(); + let base = checkpoints + .get(&base_checkpoint.commit_sha) + .context(format!( + "invalid base checkpoint: {}", + base_checkpoint.commit_sha + ))?; + let target = checkpoints + .get(&target_checkpoint.commit_sha) + .context(format!( + "invalid target checkpoint: {}", + target_checkpoint.commit_sha + ))?; + + fn collect_files( + entry: &FakeFsEntry, + prefix: String, + out: &mut std::collections::BTreeMap, + ) { + match entry { + FakeFsEntry::File { content, .. } => { + out.insert(prefix, String::from_utf8_lossy(content).into_owned()); + } + FakeFsEntry::Dir { entries, .. } => { + for (name, child) in entries { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + collect_files(child, path, out); + } + } + FakeFsEntry::Symlink { .. } => {} + } + } + + let mut base_files = std::collections::BTreeMap::new(); + let mut target_files = std::collections::BTreeMap::new(); + collect_files(base, String::new(), &mut base_files); + collect_files(target, String::new(), &mut target_files); + + let all_paths: std::collections::BTreeSet<&String> = + base_files.keys().chain(target_files.keys()).collect(); + + let mut diff = String::new(); + for path in all_paths { + match (base_files.get(path), target_files.get(path)) { + (Some(base_content), Some(target_content)) + if base_content != target_content => + { + diff.push_str(&format!("diff --git a/{path} b/{path}\n")); + diff.push_str(&format!("--- a/{path}\n")); + diff.push_str(&format!("+++ b/{path}\n")); + for line in base_content.lines() { + diff.push_str(&format!("-{line}\n")); + } + for line in target_content.lines() { + diff.push_str(&format!("+{line}\n")); + } + } + (Some(_), None) => { + diff.push_str(&format!("diff --git a/{path} /dev/null\n")); + diff.push_str("deleted file\n"); + } + (None, Some(_)) => { + diff.push_str(&format!("diff --git /dev/null b/{path}\n")); + diff.push_str("new file\n"); + } + _ => {} + } + } + Ok(diff) + } + .boxed() } fn default_branch( diff --git a/crates/fs/tests/integration/fake_git_repo.rs b/crates/fs/tests/integration/fake_git_repo.rs index e327f92e996bfa0e89cc60a0a9c0d919bec8bc47..6428083c161235001ef29daf3583520e7f7d25a2 100644 --- a/crates/fs/tests/integration/fake_git_repo.rs +++ b/crates/fs/tests/integration/fake_git_repo.rs @@ -155,7 +155,10 @@ async fn test_checkpoints(executor: BackgroundExecutor) { .unwrap() ); - repository.restore_checkpoint(checkpoint_1).await.unwrap(); + repository + .restore_checkpoint(checkpoint_1.clone()) + .await + .unwrap(); assert_eq!( fs.files_with_contents(Path::new("")), [ @@ -164,4 +167,22 @@ async fn test_checkpoints(executor: BackgroundExecutor) { (Path::new(path!("/foo/b")).into(), b"ipsum".into()) ] ); + + // diff_checkpoints: identical checkpoints produce empty diff + let diff = repository + .diff_checkpoints(checkpoint_2.clone(), checkpoint_3.clone()) + .await + .unwrap(); + assert!( + diff.is_empty(), + "identical checkpoints should produce empty diff" + ); + + // diff_checkpoints: different checkpoints produce non-empty diff + let diff = repository + .diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone()) + .await + .unwrap(); + assert!(diff.contains("b"), "diff should mention changed file 'b'"); + assert!(diff.contains("c"), "diff should mention added file 'c'"); } diff --git a/crates/fuzzy/src/char_bag.rs b/crates/fuzzy/src/char_bag.rs index 13b00816ed0141117fb6d5ac9265e4b82c7aa57d..1821a63793337862d9d6ad01a6a42072588d7be5 100644 --- a/crates/fuzzy/src/char_bag.rs +++ b/crates/fuzzy/src/char_bag.rs @@ -1,5 +1,9 @@ use std::iter::FromIterator; +pub fn simple_lowercase(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] pub struct CharBag(u64); @@ -9,7 +13,7 @@ impl CharBag { } fn insert(&mut self, c: char) { - let c = c.to_ascii_lowercase(); + let c = simple_lowercase(c); if c.is_ascii_lowercase() { let mut count = self.0; let idx = c as u8 - b'a'; diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs index 782c9caca832d81fb6e4bce8f49b4f310664b292..102708d2fad6b560b1a606c34246033587affdda 100644 --- a/crates/fuzzy/src/matcher.rs +++ b/crates/fuzzy/src/matcher.rs @@ -1,10 +1,9 @@ use std::{ borrow::Borrow, - collections::BTreeMap, sync::atomic::{self, AtomicBool}, }; -use crate::CharBag; +use crate::{CharBag, char_bag::simple_lowercase}; const BASE_DISTANCE_PENALTY: f64 = 0.6; const ADDITIONAL_DISTANCE_PENALTY: f64 = 0.05; @@ -69,7 +68,6 @@ impl<'a> Matcher<'a> { { let mut candidate_chars = Vec::new(); let mut lowercase_candidate_chars = Vec::new(); - let mut extra_lowercase_chars = BTreeMap::new(); for candidate in candidates { if !candidate.borrow().has_chars(self.query_char_bag) { @@ -82,14 +80,9 @@ impl<'a> Matcher<'a> { candidate_chars.clear(); lowercase_candidate_chars.clear(); - extra_lowercase_chars.clear(); - for (i, c) in candidate.borrow().candidate_chars().enumerate() { + for c in candidate.borrow().candidate_chars() { candidate_chars.push(c); - let mut char_lowercased = c.to_lowercase().collect::>(); - if char_lowercased.len() > 1 { - extra_lowercase_chars.insert(i, char_lowercased.len() - 1); - } - lowercase_candidate_chars.append(&mut char_lowercased); + lowercase_candidate_chars.push(simple_lowercase(c)); } if !self.find_last_positions(lowercase_prefix, &lowercase_candidate_chars) { @@ -108,7 +101,6 @@ impl<'a> Matcher<'a> { &lowercase_candidate_chars, prefix, lowercase_prefix, - &extra_lowercase_chars, ); if score > 0.0 { @@ -146,7 +138,6 @@ impl<'a> Matcher<'a> { path_lowercased: &[char], prefix: &[char], lowercase_prefix: &[char], - extra_lowercase_chars: &BTreeMap, ) -> f64 { let score = self.recursive_score_match( path, @@ -156,7 +147,6 @@ impl<'a> Matcher<'a> { 0, 0, self.query.len() as f64, - extra_lowercase_chars, ) * self.query.len() as f64; if score <= 0.0 { @@ -201,7 +191,6 @@ impl<'a> Matcher<'a> { query_idx: usize, path_idx: usize, cur_score: f64, - extra_lowercase_chars: &BTreeMap, ) -> f64 { if query_idx == self.query.len() { return 1.0; @@ -228,13 +217,6 @@ impl<'a> Matcher<'a> { let mut last_slash = 0; for j in path_idx..=safe_limit { - let extra_lowercase_chars_count = extra_lowercase_chars - .iter() - .take_while(|&(&i, _)| i < j) - .map(|(_, increment)| increment) - .sum::(); - let j_regular = j - extra_lowercase_chars_count; - let path_char = if j < prefix.len() { lowercase_prefix[j] } else { @@ -247,20 +229,20 @@ impl<'a> Matcher<'a> { let is_path_sep = path_char == '/'; if query_idx == 0 && is_path_sep { - last_slash = j_regular; + last_slash = j; } let need_to_score = query_char == path_char || (is_path_sep && query_char == '_'); if need_to_score { - let curr = match prefix.get(j_regular) { + let curr = match prefix.get(j) { Some(&curr) => curr, - None => path[j_regular - prefix.len()], + None => path[j - prefix.len()], }; let mut char_score = 1.0; if j > path_idx { - let last = match prefix.get(j_regular - 1) { + let last = match prefix.get(j - 1) { Some(&last) => last, - None => path[j_regular - 1 - prefix.len()], + None => path[j - 1 - prefix.len()], }; if last == '/' { @@ -316,12 +298,11 @@ impl<'a> Matcher<'a> { query_idx + 1, j + 1, next_score, - extra_lowercase_chars, ) * multiplier; if new_score > score { score = new_score; - best_position = j_regular; + best_position = j; // Optimization: can't score better than 1. if new_score == 1.0 { break; @@ -469,12 +450,12 @@ mod tests { assert_eq!( match_single_path_query("İo/oluş", false, &mixed_unicode_paths), - vec![("İolu/oluş", vec![0, 2, 4, 6, 8, 10, 12])] + vec![("İolu/oluş", vec![0, 2, 5, 6, 7, 8, 9])] ); assert_eq!( match_single_path_query("İst/code", false, &mixed_unicode_paths), - vec![("İstanbul/code", vec![0, 2, 4, 6, 8, 10, 12, 14])] + vec![("İstanbul/code", vec![0, 2, 3, 9, 10, 11, 12, 13])] ); assert_eq!( @@ -536,12 +517,60 @@ mod tests { ); } + #[test] + fn test_positions_are_valid_char_boundaries_with_expanding_lowercase() { + // İ (U+0130) lowercases to "i\u{307}" (2 chars) under full case folding. + // With simple case mapping (used by this matcher), İ → 'i' (1 char), + // so positions remain valid byte boundaries. + let paths = vec!["İstanbul/code.rs", "aİbİc/dİeİf.txt", "src/İmport/İndex.ts"]; + + for query in &["code", "İst", "dİe", "İndex", "İmport", "abcdef"] { + let results = match_single_path_query(query, false, &paths); + for (path, positions) in &results { + for &pos in positions { + assert!( + path.is_char_boundary(pos), + "Position {pos} is not a valid char boundary in path {path:?} \ + (query: {query:?}, all positions: {positions:?})" + ); + } + } + } + } + + #[test] + fn test_positions_valid_with_various_multibyte_chars() { + // German ß uppercases to SS but lowercases to itself — no expansion. + // Armenian ligatures and other characters that could expand under full + // case folding should still produce valid byte boundaries. + let paths = vec![ + "straße/config.rs", + "Straße/München/file.txt", + "file/path.rs", // fi (U+FB01, fi ligature) + "ffoo/bar.txt", // ff (U+FB00, ff ligature) + "aÇbŞc/dÖeÜf.txt", // Turkish chars that don't expand + ]; + + for query in &["config", "Mün", "file", "bar", "abcdef", "straße", "ÇŞ"] { + let results = match_single_path_query(query, false, &paths); + for (path, positions) in &results { + for &pos in positions { + assert!( + path.is_char_boundary(pos), + "Position {pos} is not a valid char boundary in path {path:?} \ + (query: {query:?}, all positions: {positions:?})" + ); + } + } + } + } + fn match_single_path_query<'a>( query: &str, smart_case: bool, paths: &[&'a str], ) -> Vec<(&'a str, Vec)> { - let lowercase_query = query.to_lowercase().chars().collect::>(); + let lowercase_query = query.chars().map(simple_lowercase).collect::>(); let query = query.chars().collect::>(); let query_chars = CharBag::from(&lowercase_query[..]); @@ -551,7 +580,7 @@ mod tests { .collect::>(); let mut path_entries = Vec::new(); for (i, path) in paths.iter().enumerate() { - let lowercase_path = path.to_lowercase().chars().collect::>(); + let lowercase_path: Vec = path.chars().map(simple_lowercase).collect(); let char_bag = CharBag::from(lowercase_path.as_slice()); path_entries.push(PathMatchCandidate { is_dir: false, diff --git a/crates/fuzzy/src/paths.rs b/crates/fuzzy/src/paths.rs index cce0e082840c4cd05d6e2b21eac0073d3eb7700f..2f92f05b96a3be2da7053365d8a7c53722db6ab8 100644 --- a/crates/fuzzy/src/paths.rs +++ b/crates/fuzzy/src/paths.rs @@ -10,6 +10,7 @@ use util::{paths::PathStyle, rel_path::RelPath}; use crate::{ CharBag, + char_bag::simple_lowercase, matcher::{MatchCandidate, Matcher}, }; @@ -94,7 +95,7 @@ pub fn match_fixed_path_set( max_results: usize, path_style: PathStyle, ) -> Vec { - let lowercase_query = query.to_lowercase().chars().collect::>(); + let lowercase_query = query.chars().map(simple_lowercase).collect::>(); let query = query.chars().collect::>(); let query_char_bag = CharBag::from(&lowercase_query[..]); @@ -110,7 +111,7 @@ pub fn match_fixed_path_set( path_prefix_chars.extend(path_style.primary_separator().chars()); let lowercase_pfx = path_prefix_chars .iter() - .map(|c| c.to_ascii_lowercase()) + .map(|c| simple_lowercase(*c)) .collect::>(); (worktree_root_name, path_prefix_chars, lowercase_pfx) @@ -171,7 +172,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( let lowercase_query = query .iter() - .map(|query| query.to_ascii_lowercase()) + .map(|query| simple_lowercase(*query)) .collect::>(); let query = &query; @@ -217,7 +218,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( } let lowercase_prefix = prefix .iter() - .map(|c| c.to_ascii_lowercase()) + .map(|c| simple_lowercase(*c)) .collect::>(); matcher.match_candidates( &prefix, diff --git a/crates/fuzzy/src/strings.rs b/crates/fuzzy/src/strings.rs index 54539840cfb0ca251428d9f78d5d134f16afdf4c..fb191bd9dcadd81a5a9890032ef8b185cdf7342e 100644 --- a/crates/fuzzy/src/strings.rs +++ b/crates/fuzzy/src/strings.rs @@ -1,5 +1,6 @@ use crate::{ CharBag, + char_bag::simple_lowercase, matcher::{MatchCandidate, Matcher}, }; use gpui::BackgroundExecutor; @@ -141,7 +142,7 @@ where .collect(); } - let lowercase_query = query.to_lowercase().chars().collect::>(); + let lowercase_query = query.chars().map(simple_lowercase).collect::>(); let query = query.chars().collect::>(); let lowercase_query = &lowercase_query; diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index cc3374a85932435d010daabdfe0e4b4eef628de6..e9e31a8361e367275c994e125ae6e04cbd652fc3 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -24,7 +24,6 @@ anyhow.workspace = true collections.workspace = true db.workspace = true editor.workspace = true -feature_flags.workspace = true git.workspace = true git_ui.workspace = true gpui.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index d473fbbec618c6e7b309ab2ff9dc9eb5787ddc43..83cd01eda5c509583f24fd424426d20a55bbfbed 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,6 +1,5 @@ use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; -use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, @@ -26,7 +25,7 @@ use project::git_store::{ }; use search::{ SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, - ToggleCaseSensitive, + ToggleCaseSensitive, buffer_search, }; use settings::Settings; use smallvec::{SmallVec, smallvec}; @@ -42,8 +41,10 @@ use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, - HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, - TableResizeBehavior, Tooltip, WithScrollbar, prelude::*, + HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, + TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, + bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles, + render_table_header, table_row::TableRow, }; use workspace::{ Workspace, @@ -274,6 +275,8 @@ actions!( [ /// Opens the commit view for the selected commit. OpenCommitView, + /// Focuses the search field. + FocusSearch, ] ); @@ -730,8 +733,7 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut workspace::Workspace, _, _| { workspace.register_action_renderer(|div, workspace, _, cx| { div.when( - workspace.project().read(cx).active_repository(cx).is_some() - && cx.has_flag::(), + workspace.project().read(cx).active_repository(cx).is_some(), |div| { let workspace = workspace.weak_handle(); @@ -833,8 +835,8 @@ pub fn init(cx: &mut App) { .detach(); } -fn lane_center_x(bounds: Bounds, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels { - bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset +fn lane_center_x(bounds: Bounds, lane: f32) -> Pixels { + bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 } fn to_row_center( @@ -901,9 +903,7 @@ pub struct GitGraph { context_menu: Option<(Entity, Point, Subscription)>, row_height: Pixels, table_interaction_state: Entity, - table_column_widths: Entity, - horizontal_scroll_offset: Pixels, - graph_viewport_width: Pixels, + column_widths: Entity, selected_entry_idx: Option, hovered_entry_idx: Option, graph_canvas_bounds: Rc>>>, @@ -933,8 +933,52 @@ impl GitGraph { font_size + px(12.0) } - fn graph_content_width(&self) -> Pixels { - (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0 + fn graph_canvas_content_width(&self) -> Pixels { + (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0 + } + + fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] { + let fractions = self + .column_widths + .read(cx) + .preview_fractions(window.rem_size()); + [ + fractions[0], + fractions[1], + fractions[2], + fractions[3], + fractions[4], + ] + } + + fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig { + let [_, description, date, author, commit] = self.preview_column_fractions(window, cx); + let table_total = description + date + author + commit; + + let widths = if table_total > 0.0 { + vec![ + DefiniteLength::Fraction(description / table_total), + DefiniteLength::Fraction(date / table_total), + DefiniteLength::Fraction(author / table_total), + DefiniteLength::Fraction(commit / table_total), + ] + } else { + vec![ + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + ] + }; + + ColumnWidthConfig::explicit(widths) + } + + fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels { + self.column_widths + .read(cx) + .preview_column_width(0, window) + .unwrap_or_else(|| self.graph_canvas_content_width()) } pub fn new( @@ -972,20 +1016,22 @@ impl GitGraph { }); let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); - let table_column_widths = cx.new(|_cx| { + let column_widths = cx.new(|_cx| { RedistributableColumnsState::new( - 4, + 5, vec![ - DefiniteLength::Fraction(0.72), - DefiniteLength::Fraction(0.12), - DefiniteLength::Fraction(0.10), - DefiniteLength::Fraction(0.06), + DefiniteLength::Fraction(0.14), + DefiniteLength::Fraction(0.6192), + DefiniteLength::Fraction(0.1032), + DefiniteLength::Fraction(0.086), + DefiniteLength::Fraction(0.0516), ], vec![ TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, ], ) }); @@ -1020,9 +1066,7 @@ impl GitGraph { context_menu: None, row_height, table_interaction_state, - table_column_widths, - horizontal_scroll_offset: px(0.), - graph_viewport_width: px(88.), + column_widths, selected_entry_idx: None, hovered_entry_idx: None, graph_canvas_bounds: Rc::new(Cell::new(None)), @@ -1104,7 +1148,7 @@ impl GitGraph { } } } - RepositoryEvent::BranchChanged => { + RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => { self.pending_select_sha = None; // Only invalidate if we scanned atleast once, // meaning we are not inside the initial repo loading state @@ -1113,6 +1157,12 @@ impl GitGraph { self.invalidate_state(cx); } } + RepositoryEvent::StashEntriesChanged if self.log_source == LogSource::All => { + self.pending_select_sha = None; + if repository.read(cx).scan_id > 1 { + self.invalidate_state(cx); + } + } RepositoryEvent::GraphEvent(_, _) => {} _ => {} } @@ -2087,10 +2137,13 @@ impl GitGraph { let first_visible_row = (scroll_offset_y / row_height).floor() as usize; let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height); - let horizontal_scroll_offset = self.horizontal_scroll_offset; - let max_lanes = self.graph_data.max_lanes.max(6); - let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; + let graph_viewport_width = self.graph_viewport_width(window, cx); + let graph_width = if self.graph_canvas_content_width() > graph_viewport_width { + self.graph_canvas_content_width() + } else { + graph_viewport_width + }; let last_visible_row = first_visible_row + (viewport_height / row_height).ceil() as usize + 1; @@ -2158,8 +2211,7 @@ impl GitGraph { bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0 - vertical_scroll_offset; - let commit_x = - lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset); + let commit_x = lane_center_x(bounds, row.lane as f32); draw_commit_circle(commit_x, row_y_center, row_color, window); } @@ -2171,8 +2223,7 @@ impl GitGraph { continue; }; - let line_x = - lane_center_x(bounds, start_column as f32, horizontal_scroll_offset); + let line_x = lane_center_x(bounds, start_column as f32); let start_row = line.full_interval.start as i32 - first_visible_row as i32; @@ -2217,11 +2268,7 @@ impl GitGraph { on_row, curve_kind, } => { - let mut to_column = lane_center_x( - bounds, - *to_column as f32, - horizontal_scroll_offset, - ); + let mut to_column = lane_center_x(bounds, *to_column as f32); let mut to_row = to_row_center( *on_row - first_visible_row, @@ -2414,25 +2461,8 @@ impl GitGraph { let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); let new_offset = Point::new(current_offset.x, new_y); - let max_lanes = self.graph_data.max_lanes.max(1); - let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; - let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.)); - - let new_horizontal_offset = - (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll); - - let vertical_changed = new_offset != current_offset; - let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset; - - if vertical_changed { + if new_offset != current_offset { table_state.set_scroll_offset(new_offset); - } - - if horizontal_changed { - self.horizontal_scroll_offset = new_horizontal_offset; - } - - if vertical_changed || horizontal_changed { cx.notify(); } } @@ -2527,118 +2557,200 @@ impl Render for GitGraph { this.child(self.render_loading_spinner(cx)) }) } else { - div() + let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx); + let header_context = TableRenderContext::for_column_widths( + Some(self.column_widths.read(cx).widths_to_render()), + true, + ); + let [ + graph_fraction, + description_fraction, + date_fraction, + author_fraction, + commit_fraction, + ] = self.preview_column_fractions(window, cx); + let table_fraction = + description_fraction + date_fraction + author_fraction + commit_fraction; + let table_width_config = self.table_column_width_config(window, cx); + + h_flex() .size_full() - .flex() - .flex_row() .child( div() - .w(self.graph_content_width()) - .h_full() + .flex_1() + .min_w_0() + .size_full() .flex() .flex_col() - .child( - div() - .flex() - .items_center() - .px_1() - .py_0p5() - .border_b_1() - .whitespace_nowrap() - .border_color(cx.theme().colors().border) - .child(Label::new("Graph").color(Color::Muted)), - ) - .child( - div() - .id("graph-canvas") - .flex_1() - .overflow_hidden() - .child(self.render_graph(window, cx)) - .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)) - .on_mouse_move(cx.listener(Self::handle_graph_mouse_move)) - .on_click(cx.listener(Self::handle_graph_click)) - .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| { - if !is_hovered && this.hovered_entry_idx.is_some() { - this.hovered_entry_idx = None; - cx.notify(); - } - })), - ), - ) - .child({ - let row_height = self.row_height; - let selected_entry_idx = self.selected_entry_idx; - let hovered_entry_idx = self.hovered_entry_idx; - let weak_self = cx.weak_entity(); - let focus_handle = self.focus_handle.clone(); - div().flex_1().size_full().child( - Table::new(4) - .interactable(&self.table_interaction_state) - .hide_row_borders() - .hide_row_hover() - .header(vec![ - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ]) - .width_config(ColumnWidthConfig::redistributable( - self.table_column_widths.clone(), - )) - .map_row(move |(index, row), window, cx| { - let is_selected = selected_entry_idx == Some(index); - let is_hovered = hovered_entry_idx == Some(index); - let is_focused = focus_handle.is_focused(window); - let weak = weak_self.clone(); - let weak_for_hover = weak.clone(); - - let hover_bg = cx.theme().colors().element_hover.opacity(0.6); - let selected_bg = if is_focused { - cx.theme().colors().element_selected - } else { - cx.theme().colors().element_hover - }; - - row.h(row_height) - .when(is_selected, |row| row.bg(selected_bg)) - .when(is_hovered && !is_selected, |row| row.bg(hover_bg)) - .on_hover(move |&is_hovered, _, cx| { - weak_for_hover - .update(cx, |this, cx| { - if is_hovered { - if this.hovered_entry_idx != Some(index) { - this.hovered_entry_idx = Some(index); - cx.notify(); - } - } else if this.hovered_entry_idx == Some(index) { - // Only clear if this row was the hovered one - this.hovered_entry_idx = None; - cx.notify(); - } - }) - .ok(); - }) - .on_click(move |event, window, cx| { - let click_count = event.click_count(); - weak.update(cx, |this, cx| { - this.select_entry(index, ScrollStrategy::Center, cx); - if click_count >= 2 { - this.open_commit_view(index, window, cx); - } - }) - .ok(); - }) - .into_any_element() - }) - .uniform_list( - "git-graph-commits", - commit_count, - cx.processor(Self::render_table_rows), + .child(render_table_header( + TableRow::from_vec( + vec![ + Label::new("Graph") + .color(Color::Muted) + .truncate() + .into_any_element(), + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date").color(Color::Muted).into_any_element(), + Label::new("Author").color(Color::Muted).into_any_element(), + Label::new("Commit").color(Color::Muted).into_any_element(), + ], + 5, ), - ) - }) + header_context, + Some(header_resize_info), + Some(self.column_widths.entity_id()), + cx, + )) + .child({ + let row_height = self.row_height; + let selected_entry_idx = self.selected_entry_idx; + let hovered_entry_idx = self.hovered_entry_idx; + let weak_self = cx.weak_entity(); + let focus_handle = self.focus_handle.clone(); + + bind_redistributable_columns( + div() + .relative() + .flex_1() + .w_full() + .overflow_hidden() + .child( + h_flex() + .size_full() + .child( + div() + .w(DefiniteLength::Fraction(graph_fraction)) + .h_full() + .min_w_0() + .overflow_hidden() + .child( + div() + .id("graph-canvas") + .size_full() + .overflow_hidden() + .child( + div() + .size_full() + .child(self.render_graph(window, cx)), + ) + .on_scroll_wheel( + cx.listener(Self::handle_graph_scroll), + ) + .on_mouse_move( + cx.listener(Self::handle_graph_mouse_move), + ) + .on_click(cx.listener(Self::handle_graph_click)) + .on_hover(cx.listener( + |this, &is_hovered: &bool, _, cx| { + if !is_hovered + && this.hovered_entry_idx.is_some() + { + this.hovered_entry_idx = None; + cx.notify(); + } + }, + )), + ), + ) + .child( + div() + .w(DefiniteLength::Fraction(table_fraction)) + .h_full() + .min_w_0() + .child( + Table::new(4) + .interactable(&self.table_interaction_state) + .hide_row_borders() + .hide_row_hover() + .width_config(table_width_config) + .map_row(move |(index, row), window, cx| { + let is_selected = + selected_entry_idx == Some(index); + let is_hovered = + hovered_entry_idx == Some(index); + let is_focused = + focus_handle.is_focused(window); + let weak = weak_self.clone(); + let weak_for_hover = weak.clone(); + + let hover_bg = cx + .theme() + .colors() + .element_hover + .opacity(0.6); + let selected_bg = if is_focused { + cx.theme().colors().element_selected + } else { + cx.theme().colors().element_hover + }; + + row.h(row_height) + .when(is_selected, |row| row.bg(selected_bg)) + .when( + is_hovered && !is_selected, + |row| row.bg(hover_bg), + ) + .on_hover(move |&is_hovered, _, cx| { + weak_for_hover + .update(cx, |this, cx| { + if is_hovered { + if this.hovered_entry_idx + != Some(index) + { + this.hovered_entry_idx = + Some(index); + cx.notify(); + } + } else if this + .hovered_entry_idx + == Some(index) + { + this.hovered_entry_idx = + None; + cx.notify(); + } + }) + .ok(); + }) + .on_click(move |event, window, cx| { + let click_count = event.click_count(); + weak.update(cx, |this, cx| { + this.select_entry( + index, + ScrollStrategy::Center, + cx, + ); + if click_count >= 2 { + this.open_commit_view( + index, + window, + cx, + ); + } + }) + .ok(); + }) + .into_any_element() + }) + .uniform_list( + "git-graph-commits", + commit_count, + cx.processor(Self::render_table_rows), + ), + ), + ), + ) + .child(render_redistributable_columns_resize_handles( + &self.column_widths, + window, + cx, + )), + self.column_widths.clone(), + ) + }), + ) .on_drag_move::(cx.listener(|this, event, window, cx| { this.commit_details_split_state.update(cx, |state, cx| { state.on_drag_move(event, window, cx); @@ -2664,6 +2776,11 @@ impl Render for GitGraph { this.open_selected_commit_view(window, cx); })) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|this, _: &FocusSearch, window, cx| { + this.search_state + .editor + .update(cx, |editor, cx| editor.focus_handle(cx).focus(window, cx)); + })) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) @@ -2695,6 +2812,10 @@ impl Render for GitGraph { ) .with_priority(1) })) + .on_action(cx.listener(|_, _: &buffer_search::Deploy, window, cx| { + window.dispatch_action(Box::new(FocusSearch), cx); + cx.stop_propagation(); + })) } } @@ -3622,8 +3743,8 @@ mod tests { assert!( observed_repository_events .iter() - .any(|event| matches!(event, RepositoryEvent::BranchChanged)), - "initial repository scan should emit BranchChanged" + .any(|event| matches!(event, RepositoryEvent::HeadChanged)), + "initial repository scan should emit HeadChanged" ); let commit_count_after = repository.read_with(cx, |repo, _| { repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default()) @@ -3734,16 +3855,155 @@ mod tests { }); cx.run_until_parked(); - git_graph.update_in(&mut *cx, |this, window, cx| { - this.render(window, cx); - }); + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); cx.run_until_parked(); - let commit_count_after_switch_back = + // Verify graph data is reloaded from repository cache on switch back + let reloaded_commit_count = git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len()); assert_eq!( - initial_commit_count, commit_count_after_switch_back, - "graph_data should be repopulated from cache after switching back to the same repo" + reloaded_commit_count, + commits.len(), + "graph data should be reloaded after switching back" ); } + + #[gpui::test] + async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let initial_head = Oid::from_bytes(&[1; 20]).unwrap(); + let initial_stash = Oid::from_bytes(&[2; 20]).unwrap(); + let updated_head = Oid::from_bytes(&[3; 20]).unwrap(); + let updated_stash = Oid::from_bytes(&[4; 20]).unwrap(); + + fs.set_graph_commits( + Path::new("/project/.git"), + vec![ + Arc::new(InitialGraphCommitData { + sha: initial_head, + parents: smallvec![initial_stash], + ref_names: vec!["HEAD".into(), "refs/heads/main".into()], + }), + Arc::new(InitialGraphCommitData { + sha: initial_stash, + parents: smallvec![], + ref_names: vec!["refs/stash".into()], + }), + ], + ); + fs.with_git_state(Path::new("/project/.git"), true, |state| { + state.stash_entries = git::stash::GitStash { + entries: vec![git::stash::StashEntry { + index: 0, + oid: initial_stash, + message: "initial stash".to_string(), + branch: Some("main".to_string()), + timestamp: 1, + }] + .into(), + }; + }) + .unwrap(); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + window, + cx, + ) + }); + cx.run_until_parked(); + + let initial_shas = git_graph.read_with(&*cx, |graph, _| { + graph + .graph_data + .commits + .iter() + .map(|commit| commit.data.sha) + .collect::>() + }); + assert_eq!(initial_shas, vec![initial_head, initial_stash]); + + fs.set_graph_commits( + Path::new("/project/.git"), + vec![ + Arc::new(InitialGraphCommitData { + sha: updated_head, + parents: smallvec![updated_stash], + ref_names: vec!["HEAD".into(), "refs/heads/main".into()], + }), + Arc::new(InitialGraphCommitData { + sha: updated_stash, + parents: smallvec![], + ref_names: vec!["refs/stash".into()], + }), + ], + ); + fs.with_git_state(Path::new("/project/.git"), true, |state| { + state.stash_entries = git::stash::GitStash { + entries: vec![git::stash::StashEntry { + index: 0, + oid: updated_stash, + message: "updated stash".to_string(), + branch: Some("main".to_string()), + timestamp: 1, + }] + .into(), + }; + }) + .unwrap(); + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); + cx.run_until_parked(); + + let reloaded_shas = git_graph.read_with(&*cx, |graph, _| { + graph + .graph_data + .commits + .iter() + .map(|commit| commit.data.sha) + .collect::>() + }); + assert_eq!(reloaded_shas, vec![updated_head, updated_stash]); + } } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index d95e25fbc7821d42fac4386b522c4effb9462715..e06d16708697f721d9377365223dc444ba7b08ae 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -27,7 +27,6 @@ db.workspace = true editor.workspace = true file_icons.workspace = true futures.workspace = true -feature_flags.workspace = true fuzzy.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index a298380336515aad24e9c55d637d392fa6898b35..aac44c7f9c6eaf6f18c72bea390c0a0b7ad1a4bd 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -3,7 +3,6 @@ use buffer_diff::BufferDiff; use collections::HashMap; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; -use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git::{ @@ -1045,21 +1044,19 @@ impl Render for CommitViewToolbar { }), ) .when(!is_stash, |this| { - this.when(cx.has_flag::(), |this| { - this.child( - IconButton::new("show-in-git-graph", IconName::GitGraph) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Show in Git Graph")) - .on_click(move |_, window, cx| { - window.dispatch_action( - Box::new(crate::git_panel::OpenAtCommit { - sha: sha_for_graph.clone(), - }), - cx, - ); - }), - ) - }) + this.child( + IconButton::new("show-in-git-graph", IconName::GitGraph) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Show in Git Graph")) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(crate::git_panel::OpenAtCommit { + sha: sha_for_graph.clone(), + }), + cx, + ); + }), + ) .children(remote_info.map(|(provider_name, url)| { let icon = match provider_name.as_str() { "GitHub" => IconName::Github, diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 95d46676a80ebca3b2db1ba1d7c88edee32df9ea..25175dce48163778615c26a585cd8a6319c1735f 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -6,19 +6,19 @@ use editor::{ display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ - App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription, - Task, WeakEntity, + App, ClickEvent, Context, Empty, Entity, InteractiveElement as _, ParentElement as _, + Subscription, Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId}; use project::{ ConflictRegion, ConflictSet, ConflictSetUpdate, Project, ProjectItem as _, - git_store::{GitStoreEvent, RepositoryEvent}, + git_store::{GitStore, GitStoreEvent, RepositoryEvent}, }; use settings::Settings; -use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; -use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; +use std::{ops::Range, sync::Arc}; +use ui::{ButtonLike, Divider, Tooltip, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; -use workspace::{Workspace, notifications::simple_message_notification::MessageNotification}; +use workspace::{StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, }; @@ -433,74 +433,6 @@ fn collect_conflicted_file_paths(project: &Project, cx: &App) -> Vec { paths } -pub(crate) fn register_conflict_notification( - workspace: &mut Workspace, - cx: &mut Context, -) { - let git_store = workspace.project().read(cx).git_store().clone(); - - let last_shown_paths: Rc>> = Rc::new(RefCell::new(HashSet::default())); - - cx.subscribe(&git_store, move |workspace, _git_store, event, cx| { - let conflicts_changed = matches!( - event, - GitStoreEvent::ConflictsUpdated - | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) - ); - if !AgentSettings::get_global(cx).enabled(cx) || !conflicts_changed { - return; - } - let project = workspace.project().read(cx); - if project.is_via_collab() { - return; - } - - if workspace.is_notification_suppressed(workspace::merge_conflict_notification_id()) { - return; - } - - let paths = collect_conflicted_file_paths(project, cx); - let notification_id = workspace::merge_conflict_notification_id(); - let current_paths_set: HashSet = paths.iter().cloned().collect(); - - if paths.is_empty() { - last_shown_paths.borrow_mut().clear(); - workspace.dismiss_notification(¬ification_id, cx); - } else if *last_shown_paths.borrow() != current_paths_set { - // Only show the notification if the set of conflicted paths has changed. - // This prevents re-showing after the user dismisses it while working on the same conflicts. - *last_shown_paths.borrow_mut() = current_paths_set; - let file_count = paths.len(); - workspace.show_notification(notification_id, cx, |cx| { - cx.new(|cx| { - let message = format!( - "{file_count} file{} have unresolved merge conflicts", - if file_count == 1 { "" } else { "s" } - ); - - MessageNotification::new(message, cx) - .primary_message("Resolve with Agent") - .primary_icon(IconName::ZedAssistant) - .primary_icon_color(Color::Muted) - .primary_on_click({ - let paths = paths.clone(); - move |window, cx| { - window.dispatch_action( - Box::new(ResolveConflictedFilesWithAgent { - conflicted_file_paths: paths.clone(), - }), - cx, - ); - cx.emit(DismissEvent); - } - }) - }) - }); - } - }) - .detach(); -} - pub(crate) fn resolve_conflict( editor: WeakEntity, resolved_conflict: ConflictRegion, @@ -573,3 +505,171 @@ pub(crate) fn resolve_conflict( } }) } + +pub struct MergeConflictIndicator { + project: Entity, + conflicted_paths: Vec, + last_shown_paths: HashSet, + dismissed: bool, + _subscription: Subscription, +} + +impl MergeConflictIndicator { + pub fn new(workspace: &Workspace, cx: &mut Context) -> Self { + let project = workspace.project().clone(); + let git_store = project.read(cx).git_store().clone(); + + let subscription = cx.subscribe(&git_store, Self::on_git_store_event); + + let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx); + let last_shown_paths: HashSet = conflicted_paths.iter().cloned().collect(); + + Self { + project, + conflicted_paths, + last_shown_paths, + dismissed: false, + _subscription: subscription, + } + } + + fn on_git_store_event( + &mut self, + _git_store: Entity, + event: &GitStoreEvent, + cx: &mut Context, + ) { + let conflicts_changed = matches!( + event, + GitStoreEvent::ConflictsUpdated + | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) + ); + + let agent_settings = AgentSettings::get_global(cx); + if !agent_settings.enabled(cx) + || !agent_settings.show_merge_conflict_indicator + || !conflicts_changed + { + return; + } + + let project = self.project.read(cx); + if project.is_via_collab() { + return; + } + + let paths = collect_conflicted_file_paths(project, cx); + let current_paths_set: HashSet = paths.iter().cloned().collect(); + + if paths.is_empty() { + self.conflicted_paths.clear(); + self.last_shown_paths.clear(); + self.dismissed = false; + cx.notify(); + } else if self.last_shown_paths != current_paths_set { + self.last_shown_paths = current_paths_set; + self.conflicted_paths = paths; + self.dismissed = false; + cx.notify(); + } + } + + fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context) { + window.dispatch_action( + Box::new(ResolveConflictedFilesWithAgent { + conflicted_file_paths: self.conflicted_paths.clone(), + }), + cx, + ); + self.dismissed = true; + cx.notify(); + } + + fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { + self.dismissed = true; + cx.notify(); + } +} + +impl Render for MergeConflictIndicator { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let agent_settings = AgentSettings::get_global(cx); + if !agent_settings.enabled(cx) + || !agent_settings.show_merge_conflict_indicator + || self.conflicted_paths.is_empty() + || self.dismissed + { + return Empty.into_any_element(); + } + + let file_count = self.conflicted_paths.len(); + + let message: SharedString = format!( + "Resolve Merge Conflict{} with Agent", + if file_count == 1 { "" } else { "s" } + ) + .into(); + + let tooltip_label: SharedString = format!( + "Found {} {} across the codebase", + file_count, + if file_count == 1 { + "conflict" + } else { + "conflicts" + } + ) + .into(); + + let border_color = cx.theme().colors().text_accent.opacity(0.2); + + h_flex() + .h(rems_from_px(22.)) + .rounded_sm() + .border_1() + .border_color(border_color) + .child( + ButtonLike::new("update-button") + .child( + h_flex() + .h_full() + .gap_1() + .child( + Icon::new(IconName::GitMergeConflict) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new(message).size(LabelSize::Small)), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta( + tooltip_label.clone(), + None, + "Click to Resolve with Agent", + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.resolve_with_agent(window, cx); + })), + ) + .child( + div().border_l_1().border_color(border_color).child( + IconButton::new("dismiss-merge-conflicts", IconName::Close) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(Self::dismiss)), + ), + ) + .into_any_element() + } +} + +impl StatusItemView for MergeConflictIndicator { + fn set_active_pane_item( + &mut self, + _: Option<&dyn ItemHandle>, + _window: &mut Window, + _: &mut Context, + ) { + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 00a3b4041b91454d0587a1503b66dc3fa8629917..aac1ec1a19ab53913a830738ae528fb2c0c10248 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -20,7 +20,6 @@ use editor::{ actions::ExpandAllDiffHunks, }; use editor::{EditorStyle, RewrapOptions}; -use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use file_icons::FileIcons; use futures::StreamExt as _; use git::commit::ParsedCommitMessage; @@ -781,7 +780,7 @@ impl GitPanel { move |this, _git_store, event, window, cx| match event { GitStoreEvent::RepositoryUpdated( _, - RepositoryEvent::StatusesChanged | RepositoryEvent::BranchChanged, + RepositoryEvent::StatusesChanged | RepositoryEvent::HeadChanged, true, ) | GitStoreEvent::RepositoryAdded @@ -4529,7 +4528,6 @@ impl GitPanel { let commit = branch.most_recent_commit.as_ref()?.clone(); let workspace = self.workspace.clone(); let this = cx.entity(); - let can_open_git_graph = cx.has_flag::(); Some( h_flex() @@ -4607,18 +4605,16 @@ impl GitPanel { ), ) }) - .when(can_open_git_graph, |this| { - this.child( - panel_icon_button("git-graph-button", IconName::GitGraph) - .icon_size(IconSize::Small) - .tooltip(|_window, cx| { - Tooltip::for_action("Open Git Graph", &Open, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Open.boxed_clone(), cx) - }), - ) - }), + .child( + panel_icon_button("git-graph-button", IconName::GitGraph) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Open Git Graph", &Open, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(Open.boxed_clone(), cx) + }), + ), ), ) } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index e12e9142d081c5f083a1f9ba414d7099776f327d..7d73760e34d1b2923a247f71b04fc8b5218f380b 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -47,6 +47,8 @@ pub mod stash_picker; pub mod text_diff_view; pub mod worktree_picker; +pub use conflict_view::MergeConflictIndicator; + pub fn init(cx: &mut App) { editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); commit_view::init(cx); @@ -62,7 +64,6 @@ pub fn init(cx: &mut App) { git_panel::register(workspace); repository_selector::register(workspace); git_picker::register(workspace); - conflict_view::register_conflict_notification(workspace, cx); let project = workspace.project().read(cx); if project.is_read_only(cx) { diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 2f600ae4c5620aa0d60cfc96b2d2c767b115f8aa..1b4497be1f4ea96bd4f0431c97bb538eda9faa57 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -364,7 +364,7 @@ impl WorktreeListDelegate { workspace .update_in(cx, |workspace, window, cx| { workspace.open_workspace_for_paths( - OpenMode::Replace, + OpenMode::Activate, vec![new_worktree_path], window, cx, @@ -418,7 +418,7 @@ impl WorktreeListDelegate { return; }; let open_mode = if replace_current_window { - OpenMode::Replace + OpenMode::Activate } else { OpenMode::NewWindow }; diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index ed441e3b40534690d02b31109e719c60dd5802e0..5525f5c17d2ad33e1ce9696afded1cea5447020c 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -72,7 +72,7 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, - follow_tail: bool, + follow_state: FollowState, } /// Keeps track of a fractional scroll position within an item for restoration @@ -84,6 +84,49 @@ struct PendingScrollFraction { fraction: f32, } +/// Controls whether the list automatically follows new content at the end. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FollowMode { + /// Normal scrolling — no automatic following. + #[default] + Normal, + /// The list should auto-scroll along with the tail, when scrolled to bottom. + Tail, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum FollowState { + #[default] + Normal, + Tail { + is_following: bool, + }, +} + +impl FollowState { + fn is_following(&self) -> bool { + matches!(self, FollowState::Tail { is_following: true }) + } + + fn has_stopped_following(&self) -> bool { + matches!( + self, + FollowState::Tail { + is_following: false + } + ) + } + + fn start_following(&mut self) { + if let FollowState::Tail { + is_following: false, + } = self + { + *self = FollowState::Tail { is_following: true }; + } + } +} + /// Whether the list is scrolling from top to bottom or bottom to top. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ListAlignment { @@ -169,6 +212,7 @@ pub struct ListPrepaintState { #[derive(Clone)] enum ListItem { Unmeasured { + size_hint: Option>, focus_handle: Option, }, Measured { @@ -186,9 +230,16 @@ impl ListItem { } } + fn size_hint(&self) -> Option> { + match self { + ListItem::Measured { size, .. } => Some(*size), + ListItem::Unmeasured { size_hint, .. } => *size_hint, + } + } + fn focus_handle(&self) -> Option { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle.clone() } } @@ -196,7 +247,7 @@ impl ListItem { fn contains_focused(&self, window: &Window, cx: &App) -> bool { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle .as_ref() .is_some_and(|handle| handle.contains_focused(window, cx)) @@ -240,7 +291,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, - follow_tail: false, + follow_state: FollowState::default(), }))); this.splice(0..0, item_count); this @@ -275,37 +326,63 @@ impl ListState { /// Use this when item heights may have changed (e.g., font size changes) /// but the number and identity of items remains the same. pub fn remeasure(&self) { - let state = &mut *self.0.borrow_mut(); + let count = self.item_count(); + self.remeasure_items(0..count); + } - let new_items = state.items.iter().map(|item| ListItem::Unmeasured { - focus_handle: item.focus_handle(), - }); + /// Mark items in `range` as needing remeasurement while preserving + /// the current scroll position. Unlike [`Self::splice`], this does + /// not change the number of items or blow away `logical_scroll_top`. + /// + /// Use this when an item's content has changed and its rendered + /// height may be different (e.g., streaming text, tool results + /// loading), but the item itself still exists at the same index. + pub fn remeasure_items(&self, range: Range) { + let state = &mut *self.0.borrow_mut(); - // If there's a `logical_scroll_top`, we need to keep track of it as a - // `PendingScrollFraction`, so we can later preserve that scroll - // position proportionally to the item, in case the item's height - // changes. + // If the scroll-top item falls within the remeasured range, + // store a fractional offset so the layout can restore the + // proportional scroll position after the item is re-rendered + // at its new height. if let Some(scroll_top) = state.logical_scroll_top { - let mut cursor = state.items.cursor::(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + if range.contains(&scroll_top.item_ix) { + let mut cursor = state.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); - if let Some(item) = cursor.item() { - if let Some(size) = item.size() { - let fraction = if size.height.0 > 0.0 { - (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) - } else { - 0.0 - }; - - state.pending_scroll = Some(PendingScrollFraction { - item_ix: scroll_top.item_ix, - fraction, - }); + if let Some(item) = cursor.item() { + if let Some(size) = item.size() { + let fraction = if size.height.0 > 0.0 { + (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) + } else { + 0.0 + }; + + state.pending_scroll = Some(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction, + }); + } } } } - state.items = SumTree::from_iter(new_items, ()); + // Rebuild the tree, replacing items in the range with + // Unmeasured copies that keep their focus handles. + let new_items = { + let mut cursor = state.items.cursor::(()); + let mut new_items = cursor.slice(&Count(range.start), Bias::Right); + let invalidated = cursor.slice(&Count(range.end), Bias::Right); + new_items.extend( + invalidated.iter().map(|item| ListItem::Unmeasured { + size_hint: item.size_hint(), + focus_handle: item.focus_handle(), + }), + (), + ); + new_items.append(cursor.suffix(), ()); + new_items + }; + state.items = new_items; state.measuring_behavior.reset(); } @@ -339,7 +416,10 @@ impl ListState { new_items.extend( focus_handles.into_iter().map(|focus_handle| { spliced_count += 1; - ListItem::Unmeasured { focus_handle } + ListItem::Unmeasured { + size_hint: None, + focus_handle, + } }), (), ); @@ -414,17 +494,37 @@ impl ListState { }); } - /// Set whether the list should automatically follow the tail (auto-scroll to the end). - pub fn set_follow_tail(&self, follow: bool) { - self.0.borrow_mut().follow_tail = follow; - if follow { - self.scroll_to_end(); + /// Set the follow mode for the list. In `Tail` mode, the list + /// will auto-scroll to the end and re-engage after the user + /// scrolls back to the bottom. In `Normal` mode, no automatic + /// following occurs. + pub fn set_follow_mode(&self, mode: FollowMode) { + let state = &mut *self.0.borrow_mut(); + + match mode { + FollowMode::Normal => { + state.follow_state = FollowState::Normal; + } + FollowMode::Tail => { + state.follow_state = FollowState::Tail { is_following: true }; + if matches!(mode, FollowMode::Tail) { + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + } } } - /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end). + /// Returns whether the list is currently actively following the + /// tail (snapping to the end on each layout). pub fn is_following_tail(&self) -> bool { - self.0.borrow().follow_tail + matches!( + self.0.borrow().follow_state, + FollowState::Tail { is_following: true } + ) } /// Scroll the list to the given offset @@ -592,6 +692,7 @@ impl StateInner { if self.reset { return; } + let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); @@ -613,8 +714,10 @@ impl StateInner { }); } - if self.follow_tail && delta.y > px(0.) { - self.follow_tail = false; + if let FollowState::Tail { is_following } = &mut self.follow_state { + if delta.y > px(0.) { + *is_following = false; + } } if let Some(handler) = self.scroll_handler.as_mut() { @@ -624,7 +727,10 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), - is_following_tail: self.follow_tail, + is_following_tail: matches!( + self.follow_state, + FollowState::Tail { is_following: true } + ), }, window, cx, @@ -715,7 +821,7 @@ impl StateInner { let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); - if self.follow_tail { + if self.follow_state.is_following() { scroll_top = ListOffset { item_ix: self.items.summary().count, offset_in_item: px(0.), @@ -868,6 +974,18 @@ impl StateInner { new_items.append(cursor.suffix(), ()); self.items = new_items; + // If follow_tail mode is on but the user scrolled away + // (is_following is false), check whether the current scroll + // position has returned to the bottom. + if self.follow_state.has_stopped_following() { + let padding = self.last_padding.unwrap_or_default(); + let total_height = self.items.summary().height + padding.top + padding.bottom; + let scroll_offset = self.scroll_top(&scroll_top); + if scroll_offset + available_height >= total_height - px(1.0) { + self.follow_state.start_following(); + } + } + // If none of the visible items are focused, check if an off-screen item is focused // and include it to be rendered after the visible items so keyboard interaction continues // to work for it. @@ -1004,7 +1122,7 @@ impl StateInner { content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); - self.follow_tail = false; + self.follow_state = FollowState::Normal; if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; @@ -1152,6 +1270,7 @@ impl Element for List { { let new_items = SumTree::from_iter( state.items.iter().map(|item| ListItem::Unmeasured { + size_hint: None, focus_handle: item.focus_handle(), }), (), @@ -1238,11 +1357,18 @@ impl sum_tree::Item for ListItem { fn summary(&self, _: ()) -> Self::Summary { match self { - ListItem::Unmeasured { focus_handle } => ListItemSummary { + ListItem::Unmeasured { + size_hint, + focus_handle, + } => ListItemSummary { count: 1, rendered_count: 0, unrendered_count: 1, - height: px(0.), + height: if let Some(size) = size_hint { + size.height + } else { + px(0.) + }, has_focus_handles: focus_handle.is_some(), }, ListItem::Measured { @@ -1312,8 +1438,8 @@ mod test { use std::rc::Rc; use crate::{ - self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled, - TestAppContext, Window, div, list, point, px, size, + self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render, + Styled, TestAppContext, Window, div, list, point, px, size, }; #[gpui::test] @@ -1538,7 +1664,7 @@ mod test { }) }); - state.set_follow_tail(true); + state.set_follow_mode(FollowMode::Tail); // First paint — items are 50px, total 500px, viewport 200px. // Follow-tail should anchor to the end. @@ -1592,7 +1718,7 @@ mod test { } } - state.set_follow_tail(true); + state.set_follow_mode(FollowMode::Tail); // Paint with follow-tail — scroll anchored to the bottom. cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { @@ -1634,7 +1760,7 @@ mod test { let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); - state.set_follow_tail(true); + state.set_follow_mode(FollowMode::Tail); // Paint with follow-tail — scroll anchored to the bottom. cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { @@ -1702,7 +1828,7 @@ mod test { // Enable follow-tail — this should immediately snap the scroll anchor // to the end, like the user just sent a prompt. - state.set_follow_tail(true); + state.set_follow_mode(FollowMode::Tail); cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { view.into_any_element() @@ -1757,4 +1883,201 @@ mod test { -scroll_offset.y, max_offset.y, ); } + + /// When the user scrolls away from the bottom during follow_tail, + /// follow_tail suspends. If they scroll back to the bottom, the + /// next paint should re-engage follow_tail using fresh measurements. + #[gpui::test] + fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Scroll up — follow_tail should suspend (not fully disengage). + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(50.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + // Scroll back down to the bottom. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))), + ..Default::default() + }); + + // After a paint, follow_tail should re-engage because the + // layout confirmed we're at the true bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom" + ); + } + + /// When an item is spliced to unmeasured (0px) while follow_tail + /// is suspended, the re-engagement check should still work correctly + #[gpui::test] + fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 20 items × 50px = 1000px total, 200px viewport, 1000px + // overdraw so all items get measured during the follow_tail + // paint (matching realistic production settings). + let state = ListState::new(20, crate::ListAlignment::Top, px(1000.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Scroll up a meaningful amount — suspends follow_tail. + // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px. + // Scrolling up 200px puts us at 600px, clearly not at bottom. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(200.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + // Invalidate the last item (simulates EntryUpdated calling + // remeasure_items). This makes items.summary().height + // temporarily wrong (0px for the invalidated item). + state.remeasure_items(19..20); + + // Paint — layout re-measures the invalidated item with its true + // height. The re-engagement check uses these fresh measurements. + // Since we scrolled 200px up from the 800px max, we're at + // ~600px — NOT at the bottom, so follow_tail should NOT + // re-engage. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + !state.is_following_tail(), + "follow_tail should not falsely re-engage due to an unmeasured item \ + reducing items.summary().height" + ); + } + + /// Calling `set_follow_mode(FollowState::Normal)` or dragging the scrollbar should + /// fully disengage follow_tail — clearing any suspended state so + /// follow_tail won’t auto-re-engage. + #[gpui::test] + fn test_follow_tail_suspended_state_cleared_by_explicit_actions(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + // --- Part 1: set_follow_mode(FollowState::Normal) clears suspended state --- + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // Scroll up — suspends follow_tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(50.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + // Scroll back to the bottom — should re-engage follow_tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))), + ..Default::default() + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom" + ); + + // --- Part 2: scrollbar drag clears suspended state --- + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // Drag the scrollbar to the middle — should clear suspended state. + state.set_offset_from_scrollbar(point(px(0.), px(150.))); + + // Scroll to the bottom. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))), + ..Default::default() + }); + + // Paint — should NOT re-engage because the scrollbar drag + // cleared the suspended state. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + !state.is_following_tail(), + "follow_tail should not re-engage after scrollbar drag cleared the suspended state" + ); + } } diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index ace36d695401ce76949129197dcd05135508f7d3..8811a4159a0f539d2bae2c62242a3d5f490686ef 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1701,12 +1701,7 @@ impl rwh::HasWindowHandle for MacWindow { impl rwh::HasDisplayHandle for MacWindow { fn display_handle(&self) -> Result, rwh::HandleError> { - // SAFETY: This is a no-op on macOS - unsafe { - Ok(rwh::DisplayHandle::borrow_raw( - rwh::AppKitDisplayHandle::new().into(), - )) - } + Ok(rwh::DisplayHandle::appkit()) } } diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index 3eba5c533f80d727425cc87ae89b754afa8722b1..55f6edee21b9f2da02268c66c665c34d5b52066a 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -115,6 +115,8 @@ impl PlatformAtlas for WgpuAtlas { if let Some(mut texture) = texture_slot.take() { texture.decrement_ref_count(); if texture.is_unreferenced() { + lock.pending_uploads + .retain(|upload| upload.id != texture.id); lock.storage[id.kind] .free_list .push(texture.id.index as usize); @@ -228,7 +230,9 @@ impl WgpuAtlasState { fn flush_uploads(&mut self) { for upload in self.pending_uploads.drain(..) { - let texture = &self.storage[upload.id]; + let Some(texture) = self.storage.get(upload.id) else { + continue; + }; let bytes_per_pixel = texture.bytes_per_pixel(); self.queue.write_texture( @@ -286,6 +290,15 @@ impl ops::IndexMut for WgpuAtlasStorage { } } +impl WgpuAtlasStorage { + fn get(&self, id: AtlasTextureId) -> Option<&WgpuAtlasTexture> { + self[id.kind] + .textures + .get(id.index as usize) + .and_then(|t| t.as_ref()) + } +} + impl ops::Index for WgpuAtlasStorage { type Output = WgpuAtlasTexture; fn index(&self, id: AtlasTextureId) -> &Self::Output { @@ -341,3 +354,70 @@ impl WgpuAtlasTexture { self.live_atlas_keys == 0 } } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use gpui::{ImageId, RenderImageParams}; + use pollster::block_on; + use std::sync::Arc; + + fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { + block_on(async { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?; + Ok((Arc::new(device), Arc::new(queue))) + }) + } + + #[test] + fn before_frame_skips_uploads_for_removed_texture() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + + let atlas = WgpuAtlas::new(device, queue); + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(1), + frame_index: 0, + }); + let size = Size { + width: DevicePixels(1), + height: DevicePixels(1), + }; + let mut build = || Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))); + + // Regression test: before the fix, this panicked in flush_uploads + atlas + .get_or_insert_with(&key, &mut build)? + .expect("tile should be created"); + atlas.remove(&key); + atlas.before_frame(); + + Ok(()) + } +} diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 92255f93fd95969931c6b1ae8cb465ff628f82cb..f655c1989e2c69743032703532f91b3b517084b6 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -540,10 +540,9 @@ impl rwh::HasWindowHandle for WindowsWindow { } } -// todo(windows) impl rwh::HasDisplayHandle for WindowsWindow { fn display_handle(&self) -> std::result::Result, rwh::HandleError> { - unimplemented!() + Ok(rwh::DisplayHandle::windows()) } } diff --git a/crates/grammars/src/c/highlights.scm b/crates/grammars/src/c/highlights.scm index dc5a3bd99937eb3cd1a3af6efb7124aebc4008f1..b73c8e80b8acb61cc0cf47ed6585202eb73f4a7b 100644 --- a/crates/grammars/src/c/highlights.scm +++ b/crates/grammars/src/c/highlights.scm @@ -38,7 +38,7 @@ "#ifndef" "#include" (preproc_directive) -] @preproc +] @keyword.preproc @preproc [ "=" diff --git a/crates/grammars/src/cpp/highlights.scm b/crates/grammars/src/cpp/highlights.scm index e074707d05dec638a1be9ea840c31f47537c438a..281da4215c8269172816c6f37a5e6e866c04a140 100644 --- a/crates/grammars/src/cpp/highlights.scm +++ b/crates/grammars/src/cpp/highlights.scm @@ -196,7 +196,7 @@ type: (primitive_type) @type.builtin "#ifndef" "#include" (preproc_directive) -] @preproc +] @keyword.preproc @preproc (comment) @comment diff --git a/crates/grammars/src/diff/highlights.scm b/crates/grammars/src/diff/highlights.scm index a2e33190f154d6a210572dbb066000dca6f30455..3c9abbe147b6554d6894d5d8d3c8bcf5d93e2edd 100644 --- a/crates/grammars/src/diff/highlights.scm +++ b/crates/grammars/src/diff/highlights.scm @@ -3,15 +3,13 @@ [ (addition) (new_file) -] @string +] @string @diff.plus -; TODO: This should eventually be `@diff.plus` with a fallback of `@string` [ (deletion) (old_file) -] @keyword +] @keyword @diff.minus -; TODO: This should eventually be `@diff.minus` with a fallback of `@keyword` (commit) @constant (location) @attribute diff --git a/crates/grammars/src/javascript/highlights.scm b/crates/grammars/src/javascript/highlights.scm index 4af87cc578e3060e72d1e1374f4904d8c7629ddf..f6354dd3a016f544e5be1616c3dfb12144855775 100644 --- a/crates/grammars/src/javascript/highlights.scm +++ b/crates/grammars/src/javascript/highlights.scm @@ -328,26 +328,26 @@ ; JSX elements (jsx_opening_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_self_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_opening_element diff --git a/crates/grammars/src/tsx/highlights.scm b/crates/grammars/src/tsx/highlights.scm index 482bba7f081a44b78a2f2d72c3435d8a6419b874..0f203e7112cf14268d0edfed39b5624375d1a859 100644 --- a/crates/grammars/src/tsx/highlights.scm +++ b/crates/grammars/src/tsx/highlights.scm @@ -389,26 +389,26 @@ (jsx_opening_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_self_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_opening_element diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 6929ae4e4ca8ca0ee00c9793c948892043dd6dd6..e29b7d3593025556771d62dc0124786672c540de 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -95,6 +95,7 @@ pub enum IconName { DebugStepOver, Diff, DiffSplit, + DiffSplitAuto, DiffUnified, Disconnected, Download, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index b2ab420312249f809599d06315e706627b76570b..a467cd789555d39a32ad4e1d7b21da7b14df9c25 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -5549,11 +5549,11 @@ impl<'a> BufferChunks<'a> { && range.start >= capture.node.start_byte() { let next_capture_end = capture.node.end_byte(); - if range.start < next_capture_end { - highlights.stack.push(( - next_capture_end, - highlights.highlight_maps[capture.grammar_index].get(capture.index), - )); + if range.start < next_capture_end + && let Some(capture_id) = + highlights.highlight_maps[capture.grammar_index].get(capture.index) + { + highlights.stack.push((next_capture_end, capture_id)); } highlights.next_capture.take(); } @@ -5688,9 +5688,11 @@ impl<'a> Iterator for BufferChunks<'a> { } else { let highlight_id = highlights.highlight_maps[capture.grammar_index].get(capture.index); - highlights - .stack - .push((capture.node.end_byte(), highlight_id)); + if let Some(highlight_id) = highlight_id { + highlights + .stack + .push((capture.node.end_byte(), highlight_id)); + } highlights.next_capture = highlights.captures.next(); } } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 035cb3a2009241cc4ff97a7adf4c82de73166a76..43bbe7a08c73e476a41aec8af015464aa3af853d 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -1023,9 +1023,7 @@ impl Language { BufferChunks::new(text, range, Some((captures, highlight_maps)), false, None) { let end_offset = offset + chunk.text.len(); - if let Some(highlight_id) = chunk.syntax_highlight_id - && !highlight_id.is_default() - { + if let Some(highlight_id) = chunk.syntax_highlight_id { result.push((offset..end_offset, highlight_id)); } offset = end_offset; @@ -1077,11 +1075,11 @@ impl Language { #[inline] pub fn build_highlight_map(capture_names: &[&str], theme: &SyntaxTheme) -> HighlightMap { - HighlightMap::from_ids(capture_names.iter().map(|capture_name| { - theme - .highlight_id(capture_name) - .map_or(HighlightId::default(), HighlightId) - })) + HighlightMap::from_ids( + capture_names + .iter() + .map(|capture_name| theme.highlight_id(capture_name).map(HighlightId::new)), + ) } impl LanguageScope { @@ -1645,9 +1643,18 @@ mod tests { ]; let map = build_highlight_map(capture_names, &theme); - assert_eq!(theme.get_capture_name(map.get(0)), Some("function")); - assert_eq!(theme.get_capture_name(map.get(1)), Some("function.async")); - assert_eq!(theme.get_capture_name(map.get(2)), Some("variable.builtin")); + assert_eq!( + theme.get_capture_name(map.get(0).unwrap()), + Some("function") + ); + assert_eq!( + theme.get_capture_name(map.get(1).unwrap()), + Some("function.async") + ); + assert_eq!( + theme.get_capture_name(map.get(2).unwrap()), + Some("variable.builtin") + ); } #[gpui::test(iterations = 10)] diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs index 77e3805e52415a20f5d343bff98682744a50fdc2..54e9a3f1b3309718436b206874802779925a9d04 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -275,12 +275,11 @@ impl Grammar { } pub fn highlight_id_for_name(&self, name: &str) -> Option { - let capture_id = self - .highlights_config + self.highlights_config .as_ref()? .query - .capture_index_for_name(name)?; - Some(self.highlight_map.lock().get(capture_id)) + .capture_index_for_name(name) + .and_then(|capture_id| self.highlight_map.lock().get(capture_id)) } pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { diff --git a/crates/language_core/src/highlight_map.rs b/crates/language_core/src/highlight_map.rs index 1235c7d62c72950f57de0cdad1363f49d8fbbd96..cba5cda6f7501a04966d5ce512e2fed700724d1a 100644 --- a/crates/language_core/src/highlight_map.rs +++ b/crates/language_core/src/highlight_map.rs @@ -1,35 +1,35 @@ -use std::sync::Arc; +use std::{num::NonZeroU32, sync::Arc}; #[derive(Clone, Debug)] -pub struct HighlightMap(Arc<[HighlightId]>); +pub struct HighlightMap(Arc<[Option]>); #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HighlightId(pub u32); +pub struct HighlightId(NonZeroU32); -const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX); +impl HighlightId { + pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(NonZeroU32::new(u32::MAX - 1).unwrap()); + pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(NonZeroU32::new(u32::MAX - 2).unwrap()); -impl HighlightMap { - #[inline] - pub fn from_ids(highlight_ids: impl IntoIterator) -> Self { - Self(highlight_ids.into_iter().collect()) + pub fn new(capture_id: u32) -> Self { + Self(NonZeroU32::new(capture_id + 1).unwrap_or(NonZeroU32::MAX)) } +} - #[inline] - pub fn get(&self, capture_id: u32) -> HighlightId { - self.0 - .get(capture_id as usize) - .copied() - .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID) +impl From for usize { + fn from(value: HighlightId) -> Self { + value.0.get() as usize - 1 } } -impl HighlightId { - pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1); - pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2); +impl HighlightMap { + #[inline] + pub fn from_ids(highlight_ids: impl IntoIterator>) -> Self { + Self(highlight_ids.into_iter().collect()) + } #[inline] - pub fn is_default(&self) -> bool { - *self == DEFAULT_SYNTAX_HIGHLIGHT_ID + pub fn get(&self, capture_id: u32) -> Option { + self.0.get(capture_id as usize).copied().flatten() } } @@ -38,15 +38,3 @@ impl Default for HighlightMap { Self(Arc::new([])) } } - -impl Default for HighlightId { - fn default() -> Self { - DEFAULT_SYNTAX_HIGHLIGHT_ID - } -} - -impl From for usize { - fn from(value: HighlightId) -> Self { - value.0 as usize - } -} diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 13899f11c30556db189da48ed1fcb4b5d12b2f20..3c28e07e6b306ea3a0ce644ac688f9fab8d6125f 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -684,7 +684,7 @@ fn test_build_code_label() { ); let code_runs = code_ranges .into_iter() - .map(|range| (range, HighlightId(0))) + .map(|range| (range, HighlightId::new(0))) .collect::>(); let label = build_code_label( @@ -707,7 +707,7 @@ fn test_build_code_label() { marked_text_ranges("pqrs.tuv: «fn»(«Bcd»(«Efgh»)) -> «Ijklm»", false); let label_runs = label_ranges .into_iter() - .map(|range| (range, HighlightId(0))) + .map(|range| (range, HighlightId::new(0))) .collect::>(); assert_eq!( @@ -723,7 +723,7 @@ fn test_build_code_label_with_invalid_ranges() { let (code, code_ranges) = marked_text_ranges("const «a»: «B» = '🏀'", false); let code_runs = code_ranges .into_iter() - .map(|range| (range, HighlightId(0))) + .map(|range| (range, HighlightId::new(0))) .collect::>(); // A span uses a code range that is invalid because it starts inside of diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index 911100fc25b498ba5471c85d6177052495974665..4712d86dff6c44f9cdd8576a08349ccfa7d0ecca 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -20,11 +20,11 @@ anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true credentials_provider.workspace = true base64.workspace = true -client.workspace = true cloud_api_client.workspace = true cloud_api_types.workspace = true cloud_llm_client.workspace = true collections.workspace = true +env_var.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true @@ -40,7 +40,6 @@ serde_json.workspace = true smol.workspace = true thiserror.workspace = true util.workspace = true -zed_env_vars.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_model/src/api_key.rs b/crates/language_model/src/api_key.rs index 754fde069295d8799820020bef286b1a1a3c590c..4be5a64d3db6231c98b830a524d5e299faace457 100644 --- a/crates/language_model/src/api_key.rs +++ b/crates/language_model/src/api_key.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use credentials_provider::CredentialsProvider; +use env_var::EnvVar; use futures::{FutureExt, future}; use gpui::{AsyncApp, Context, SharedString, Task}; use std::{ @@ -7,7 +8,6 @@ use std::{ sync::Arc, }; use util::ResultExt as _; -use zed_env_vars::EnvVar; use crate::AuthenticateError; @@ -101,6 +101,7 @@ impl ApiKeyState { url: SharedString, key: Option, get_this: impl Fn(&mut Ent) -> &mut Self + 'static, + provider: Arc, cx: &Context, ) -> Task> { if self.is_from_env_var() { @@ -108,18 +109,14 @@ impl ApiKeyState { "bug: attempted to store API key in system keychain when API key is from env var", ))); } - let credentials_provider = ::global(cx); cx.spawn(async move |ent, cx| { if let Some(key) = &key { - credentials_provider + provider .write_credentials(&url, "Bearer", key.as_bytes(), cx) .await .log_err(); } else { - credentials_provider - .delete_credentials(&url, cx) - .await - .log_err(); + provider.delete_credentials(&url, cx).await.log_err(); } ent.update(cx, |ent, cx| { let this = get_this(ent); @@ -144,12 +141,13 @@ impl ApiKeyState { &mut self, url: SharedString, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, + provider: Arc, cx: &mut Context, ) { if url != self.url { if !self.is_from_env_var() { // loading will continue even though this result task is dropped - let _task = self.load_if_needed(url, get_this, cx); + let _task = self.load_if_needed(url, get_this, provider, cx); } } } @@ -163,6 +161,7 @@ impl ApiKeyState { &mut self, url: SharedString, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, + provider: Arc, cx: &mut Context, ) -> Task> { if let LoadStatus::Loaded { .. } = &self.load_status @@ -185,7 +184,7 @@ impl ApiKeyState { let task = if let Some(load_task) = &self.load_task { load_task.clone() } else { - let load_task = Self::load(url.clone(), get_this.clone(), cx).shared(); + let load_task = Self::load(url.clone(), get_this.clone(), provider, cx).shared(); self.url = url; self.load_status = LoadStatus::NotPresent; self.load_task = Some(load_task.clone()); @@ -206,14 +205,13 @@ impl ApiKeyState { fn load( url: SharedString, get_this: impl Fn(&mut Ent) -> &mut Self + 'static, + provider: Arc, cx: &Context, ) -> Task<()> { - let credentials_provider = ::global(cx); cx.spawn({ async move |ent, cx| { let load_status = - ApiKey::load_from_system_keychain_impl(&url, credentials_provider.as_ref(), cx) - .await; + ApiKey::load_from_system_keychain_impl(&url, provider.as_ref(), cx).await; ent.update(cx, |ent, cx| { let this = get_this(ent); this.url = url; diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index ce71cee6bcaf4f7ea1e210cc3756bd3162715f55..3f309b7b1d4152c54324efaaf0ad3bdb7035eea4 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -11,12 +11,10 @@ pub mod tool_schema; pub mod fake_provider; use anyhow::{Result, anyhow}; -use client::Client; -use client::UserStore; use cloud_llm_client::CompletionRequestStatus; use futures::FutureExt; use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window}; use http_client::{StatusCode, http}; use icons::IconName; use parking_lot::Mutex; @@ -36,15 +34,10 @@ pub use crate::registry::*; pub use crate::request::*; pub use crate::role::*; pub use crate::tool_schema::LanguageModelToolSchemaFormat; +pub use env_var::{EnvVar, env_var}; pub use provider::*; -pub use zed_env_vars::{EnvVar, env_var}; -pub fn init(user_store: Entity, client: Arc, cx: &mut App) { - init_settings(cx); - RefreshLlmTokenListener::register(client, user_store, cx); -} - -pub fn init_settings(cx: &mut App) { +pub fn init(cx: &mut App) { registry::init(cx); } diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index a1362d78292082522f4e883efe42b2ca1e0a0300..db926aab1f70a46a4e70b1b67c2c9e4c4f465c2c 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -1,16 +1,9 @@ use std::fmt; use std::sync::Arc; -use anyhow::{Context as _, Result}; -use client::Client; -use client::UserStore; use cloud_api_client::ClientApiError; +use cloud_api_client::CloudApiClient; use cloud_api_types::OrganizationId; -use cloud_api_types::websocket_protocol::MessageToClient; -use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME}; -use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription, -}; use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use thiserror::Error; @@ -30,18 +23,12 @@ impl fmt::Display for PaymentRequiredError { pub struct LlmApiToken(Arc>>); impl LlmApiToken { - pub fn global(cx: &App) -> Self { - RefreshLlmTokenListener::global(cx) - .read(cx) - .llm_api_token - .clone() - } - pub async fn acquire( &self, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { + ) -> Result { let lock = self.0.upgradable_read().await; if let Some(token) = lock.as_ref() { Ok(token.to_string()) @@ -49,6 +36,7 @@ impl LlmApiToken { Self::fetch( RwLockUpgradableReadGuard::upgrade(lock).await, client, + system_id, organization_id, ) .await @@ -57,10 +45,11 @@ impl LlmApiToken { pub async fn refresh( &self, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { - Self::fetch(self.0.write().await, client, organization_id).await + ) -> Result { + Self::fetch(self.0.write().await, client, system_id, organization_id).await } /// Clears the existing token before attempting to fetch a new one. @@ -69,28 +58,22 @@ impl LlmApiToken { /// leave a token for the wrong organization. pub async fn clear_and_refresh( &self, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { + ) -> Result { let mut lock = self.0.write().await; *lock = None; - Self::fetch(lock, client, organization_id).await + Self::fetch(lock, client, system_id, organization_id).await } async fn fetch( mut lock: RwLockWriteGuard<'_, Option>, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { - let system_id = client - .telemetry() - .system_id() - .map(|system_id| system_id.to_string()); - - let result = client - .cloud_client() - .create_llm_token(system_id, organization_id) - .await; + ) -> Result { + let result = client.create_llm_token(system_id, organization_id).await; match result { Ok(response) => { *lock = Some(response.token.0.clone()); @@ -98,112 +81,7 @@ impl LlmApiToken { } Err(err) => { *lock = None; - match err { - ClientApiError::Unauthorized => { - client.request_sign_out(); - Err(err).context("Failed to create LLM token") - } - ClientApiError::Other(err) => Err(err), - } - } - } - } -} - -pub trait NeedsLlmTokenRefresh { - /// Returns whether the LLM token needs to be refreshed. - fn needs_llm_token_refresh(&self) -> bool; -} - -impl NeedsLlmTokenRefresh for http_client::Response { - fn needs_llm_token_refresh(&self) -> bool { - self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some() - || self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some() - } -} - -enum TokenRefreshMode { - Refresh, - ClearAndRefresh, -} - -struct GlobalRefreshLlmTokenListener(Entity); - -impl Global for GlobalRefreshLlmTokenListener {} - -pub struct LlmTokenRefreshedEvent; - -pub struct RefreshLlmTokenListener { - client: Arc, - user_store: Entity, - llm_api_token: LlmApiToken, - _subscription: Subscription, -} - -impl EventEmitter for RefreshLlmTokenListener {} - -impl RefreshLlmTokenListener { - pub fn register(client: Arc, user_store: Entity, cx: &mut App) { - let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx)); - cx.set_global(GlobalRefreshLlmTokenListener(listener)); - } - - pub fn global(cx: &App) -> Entity { - GlobalRefreshLlmTokenListener::global(cx).0.clone() - } - - fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - client.add_message_to_client_handler({ - let this = cx.weak_entity(); - move |message, cx| { - if let Some(this) = this.upgrade() { - Self::handle_refresh_llm_token(this, message, cx); - } - } - }); - - let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { - if matches!(event, client::user::Event::OrganizationChanged) { - this.refresh(TokenRefreshMode::ClearAndRefresh, cx); - } - }); - - Self { - client, - user_store, - llm_api_token: LlmApiToken::default(), - _subscription: subscription, - } - } - - fn refresh(&self, mode: TokenRefreshMode, cx: &mut Context) { - let client = self.client.clone(); - let llm_api_token = self.llm_api_token.clone(); - let organization_id = self - .user_store - .read(cx) - .current_organization() - .map(|organization| organization.id.clone()); - cx.spawn(async move |this, cx| { - match mode { - TokenRefreshMode::Refresh => { - llm_api_token.refresh(&client, organization_id).await?; - } - TokenRefreshMode::ClearAndRefresh => { - llm_api_token - .clear_and_refresh(&client, organization_id) - .await?; - } - } - this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent)) - }) - .detach_and_log_err(cx); - } - - fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { - match message { - MessageToClient::UserUpdated => { - this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx)); + Err(err) } } } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 4db1db8fa6ce5afb9d77a6685bfc0861d0fb8885..3154db91a43d1381f5b3f122a724be249adeb79b 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; +use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; @@ -31,9 +32,16 @@ use crate::provider::x_ai::XAiLanguageModelProvider; pub use crate::settings::*; pub fn init(user_store: Entity, client: Arc, cx: &mut App) { + let credentials_provider = client.credentials_provider(); let registry = LanguageModelRegistry::global(cx); registry.update(cx, |registry, cx| { - register_language_model_providers(registry, user_store, client.clone(), cx); + register_language_model_providers( + registry, + user_store, + client.clone(), + credentials_provider.clone(), + cx, + ); }); // Subscribe to extension store events to track LLM extension installations @@ -104,6 +112,7 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { &HashSet::default(), &openai_compatible_providers, client.clone(), + credentials_provider.clone(), cx, ); }); @@ -124,6 +133,7 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { &openai_compatible_providers, &openai_compatible_providers_new, client.clone(), + credentials_provider.clone(), cx, ); }); @@ -138,6 +148,7 @@ fn register_openai_compatible_providers( old: &HashSet>, new: &HashSet>, client: Arc, + credentials_provider: Arc, cx: &mut Context, ) { for provider_id in old { @@ -152,6 +163,7 @@ fn register_openai_compatible_providers( Arc::new(OpenAiCompatibleLanguageModelProvider::new( provider_id.clone(), client.http_client(), + credentials_provider.clone(), cx, )), cx, @@ -164,6 +176,7 @@ fn register_language_model_providers( registry: &mut LanguageModelRegistry, user_store: Entity, client: Arc, + credentials_provider: Arc, cx: &mut Context, ) { registry.register_provider( @@ -177,62 +190,105 @@ fn register_language_model_providers( registry.register_provider( Arc::new(AnthropicLanguageModelProvider::new( client.http_client(), + credentials_provider.clone(), cx, )), cx, ); registry.register_provider( - Arc::new(OpenAiLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(OpenAiLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(OllamaLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(OllamaLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(LmStudioLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(LmStudioLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(DeepSeekLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(DeepSeekLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(GoogleLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(GoogleLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - MistralLanguageModelProvider::global(client.http_client(), cx), + MistralLanguageModelProvider::global( + client.http_client(), + credentials_provider.clone(), + cx, + ), cx, ); registry.register_provider( - Arc::new(BedrockLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(BedrockLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( Arc::new(OpenRouterLanguageModelProvider::new( client.http_client(), + credentials_provider.clone(), cx, )), cx, ); registry.register_provider( - Arc::new(VercelLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(VercelLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( Arc::new(VercelAiGatewayLanguageModelProvider::new( client.http_client(), + credentials_provider.clone(), cx, )), cx, ); registry.register_provider( - Arc::new(XAiLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(XAiLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(OpenCodeLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(OpenCodeLanguageModelProvider::new( + client.http_client(), + credentials_provider, + cx, + )), cx, ); registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx); diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index a98a0ce142dfdbaaaddc056ab378455a45147830..c1b8bc1a3bb1b602b67ae5563d8acc3b05a94d47 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -6,6 +6,7 @@ use anthropic::{ }; use anyhow::Result; use collections::{BTreeMap, HashMap}; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; use http_client::HttpClient; @@ -51,6 +52,7 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -59,30 +61,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl AnthropicLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index f53f145dbd387aa948b977d854ba77f1cbe49ded..4320763e2c5c6de7f3fe9238d7a4991565c3bfcd 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -195,12 +195,13 @@ pub struct State { settings: Option, /// Whether credentials came from environment variables (only relevant for static credentials) credentials_from_env: bool, + credentials_provider: Arc, _subscription: Subscription, } impl State { fn reset_auth(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); + let credentials_provider = self.credentials_provider.clone(); cx.spawn(async move |this, cx| { credentials_provider .delete_credentials(AMAZON_AWS_URL, cx) @@ -220,7 +221,7 @@ impl State { cx: &mut Context, ) -> Task> { let auth = credentials.clone().into_auth(); - let credentials_provider = ::global(cx); + let credentials_provider = self.credentials_provider.clone(); cx.spawn(async move |this, cx| { credentials_provider .write_credentials( @@ -287,7 +288,7 @@ impl State { &self, cx: &mut Context, ) -> Task> { - let credentials_provider = ::global(cx); + let credentials_provider = self.credentials_provider.clone(); cx.spawn(async move |this, cx| { // Try environment variables first let (auth, from_env) = if let Some(bearer_token) = &ZED_BEDROCK_BEARER_TOKEN_VAR.value { @@ -400,11 +401,16 @@ pub struct BedrockLanguageModelProvider { } impl BedrockLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| State { auth: None, settings: Some(AllLanguageModelSettings::get_global(cx).bedrock.clone()), credentials_from_env: false, + credentials_provider, _subscription: cx.observe_global::(|_, cx| { cx.notify(); }), diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index f9372a4d7ea9c078c58f633cc58bd5597ef49212..29623cc998ad0fe933e9a29c45c651f7be010b07 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,7 +1,9 @@ use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; -use client::{Client, UserStore, zed_urls}; +use client::{ + Client, NeedsLlmTokenRefresh, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls, +}; use cloud_api_types::{OrganizationId, Plan}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, @@ -24,10 +26,9 @@ use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, NeedsLlmTokenRefresh, - OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, PaymentRequiredError, RateLimiter, - RefreshLlmTokenListener, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, - ZED_CLOUD_PROVIDER_NAME, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, OPEN_AI_PROVIDER_ID, + OPEN_AI_PROVIDER_NAME, PaymentRequiredError, RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, + ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use release_channel::AppVersion; use schemars::JsonSchema; @@ -111,7 +112,7 @@ impl State { cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); - let llm_api_token = LlmApiToken::global(cx); + let llm_api_token = global_llm_token(cx); Self { client: client.clone(), llm_api_token, @@ -226,7 +227,9 @@ impl State { organization_id: Option, ) -> Result { let http_client = &client.http_client(); - let token = llm_api_token.acquire(&client, organization_id).await?; + let token = client + .acquire_llm_token(&llm_api_token, organization_id) + .await?; let request = http_client::Request::builder() .method(Method::GET) @@ -414,8 +417,8 @@ impl CloudLanguageModel { ) -> Result { let http_client = &client.http_client(); - let mut token = llm_api_token - .acquire(&client, organization_id.clone()) + let mut token = client + .acquire_llm_token(&llm_api_token, organization_id.clone()) .await?; let mut refreshed_token = false; @@ -447,8 +450,8 @@ impl CloudLanguageModel { } if !refreshed_token && response.needs_llm_token_refresh() { - token = llm_api_token - .refresh(&client, organization_id.clone()) + token = client + .refresh_llm_token(&llm_api_token, organization_id.clone()) .await?; refreshed_token = true; continue; @@ -713,7 +716,9 @@ impl LanguageModel for CloudLanguageModel { into_google(request, model_id.clone(), GoogleModelMode::Default); async move { let http_client = &client.http_client(); - let token = llm_api_token.acquire(&client, organization_id).await?; + let token = client + .acquire_llm_token(&llm_api_token, organization_id) + .await?; let request_body = CountTokensBody { provider: cloud_llm_client::LanguageModelProvider::Google, diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index bd2469d865fd8421d6ad31208e6a4be413c0fe14..0cfb1af425c7cb0279d98fa124a589437f1bb1a1 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; +use credentials_provider::CredentialsProvider; use deepseek::DEEPSEEK_API_URL; use futures::Stream; @@ -49,6 +50,7 @@ pub struct DeepSeekLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -57,30 +59,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = DeepSeekLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = DeepSeekLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl DeepSeekLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 8fdfb514ac6e872bd24968d33f2c1169401d5a9c..244f7835a85ff67f0c4826321910ea13516371cb 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -1,5 +1,6 @@ use anyhow::{Context as _, Result}; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; use google_ai::{ FunctionDeclaration, GenerateContentResponse, GoogleModelMode, Part, SystemInstruction, @@ -60,6 +61,7 @@ pub struct GoogleLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } const GEMINI_API_KEY_VAR_NAME: &str = "GEMINI_API_KEY"; @@ -76,30 +78,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = GoogleLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = GoogleLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl GoogleLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 6c8d3c6e1c50185a4b09e9afc80c688f4c8d1381..0d60fef16791087e35bac7d846b2ec99821d5470 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::HashMap; +use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; @@ -52,6 +53,7 @@ pub struct LmStudioLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, available_models: Vec, fetch_model_task: Option>>, @@ -64,10 +66,15 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = LmStudioLanguageModelProvider::api_url(cx).into(); - let task = self - .api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); self.restart_fetch_models_task(cx); task } @@ -114,10 +121,14 @@ impl State { } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = LmStudioLanguageModelProvider::api_url(cx).into(); - let _task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let _task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); if self.is_authenticated() { return Task::ready(Ok(())); @@ -152,16 +163,29 @@ impl State { } impl LmStudioLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let this = Self { http_client: http_client.clone(), state: cx.new(|cx| { let subscription = cx.observe_global::({ let mut settings = AllLanguageModelSettings::get_global(cx).lmstudio.clone(); move |this: &mut State, cx| { - let new_settings = &AllLanguageModelSettings::get_global(cx).lmstudio; - if &settings != new_settings { - settings = new_settings.clone(); + let new_settings = + AllLanguageModelSettings::get_global(cx).lmstudio.clone(); + if settings != new_settings { + let credentials_provider = this.credentials_provider.clone(); + let api_url = Self::api_url(cx).into(); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); + settings = new_settings; this.restart_fetch_models_task(cx); cx.notify(); } @@ -173,6 +197,7 @@ impl LmStudioLanguageModelProvider { Self::api_url(cx).into(), (*API_KEY_ENV_VAR).clone(), ), + credentials_provider, http_client, available_models: Default::default(), fetch_model_task: None, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 72f0cae2993da4efb3e19cb19ec42b186290920d..4cd1375fe50cd792a3a7bc8c85ba7b5b5af9520a 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; @@ -43,6 +44,7 @@ pub struct MistralLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -51,15 +53,26 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = MistralLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = MistralLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } @@ -73,20 +86,30 @@ impl MistralLanguageModelProvider { .map(|this| &this.0) } - pub fn global(http_client: Arc, cx: &mut App) -> Arc { + pub fn global( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Arc { if let Some(this) = cx.try_global::() { return this.0.clone(); } let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 551fcd55358c11bdf64bf2f27b32fa9a7f702252..49c326683a225bf73f604a584307ea1316a710c4 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -1,4 +1,5 @@ use anyhow::{Result, anyhow}; +use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; @@ -54,6 +55,7 @@ pub struct OllamaLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, fetched_models: Vec, fetch_model_task: Option>>, @@ -65,10 +67,15 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OllamaLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); self.fetched_models.clear(); cx.spawn(async move |this, cx| { @@ -80,10 +87,14 @@ impl State { } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OllamaLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); // Always try to fetch models - if no API key is needed (local Ollama), it will work // If API key is needed and provided, it will work @@ -157,7 +168,11 @@ impl State { } impl OllamaLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let this = Self { http_client: http_client.clone(), state: cx.new(|cx| { @@ -170,6 +185,14 @@ impl OllamaLanguageModelProvider { let url_changed = last_settings.api_url != current_settings.api_url; last_settings = current_settings.clone(); if url_changed { + let credentials_provider = this.credentials_provider.clone(); + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); this.fetched_models.clear(); this.authenticate(cx).detach(); } @@ -184,6 +207,7 @@ impl OllamaLanguageModelProvider { fetched_models: Default::default(), fetch_model_task: None, api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }), }; diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 9289c66b2a4c9213826d2d027555511c9746d00e..6a2313487f4a1922cdc2aa20d23ede01c4b7d158 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; +use credentials_provider::CredentialsProvider; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; @@ -55,6 +56,7 @@ pub struct OpenAiLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -63,30 +65,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenAiLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenAiLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl OpenAiLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 87a08097782198238a5d2467af32cc66b3183664..1c3268749c3340826cd2f50d29e80eecfa1826d4 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,5 +1,6 @@ use anyhow::Result; use convert_case::{Case, Casing}; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -44,6 +45,7 @@ pub struct State { id: Arc, api_key_state: ApiKeyState, settings: OpenAiCompatibleSettings, + credentials_provider: Arc, } impl State { @@ -52,20 +54,36 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = SharedString::new(self.settings.api_url.as_str()); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = SharedString::new(self.settings.api_url.clone()); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl OpenAiCompatibleLanguageModelProvider { - pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { + pub fn new( + id: Arc, + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> { crate::AllLanguageModelSettings::get_global(cx) .openai_compatible @@ -79,10 +97,12 @@ impl OpenAiCompatibleLanguageModelProvider { return; }; if &this.settings != &settings { + let credentials_provider = this.credentials_provider.clone(); let api_url = SharedString::new(settings.api_url.as_str()); this.api_key_state.handle_url_change( api_url, |this| &mut this.api_key_state, + credentials_provider, cx, ); this.settings = settings; @@ -98,6 +118,7 @@ impl OpenAiCompatibleLanguageModelProvider { EnvVar::new(api_key_env_var_name), ), settings, + credentials_provider, } }); @@ -381,7 +402,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), - None, + self.model.reasoning_effort.clone(), ); let completions = self.stream_completion(request, cx); async move { @@ -396,7 +417,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), - None, + self.model.reasoning_effort.clone(), ); let completions = self.stream_response(request, cx); async move { diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index a4a679be73c0276351a6524ad7e8fc40e2c26860..09c8eb768d12c61ed1dc86a1251ad52114be6162 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::HashMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; use http_client::HttpClient; @@ -42,6 +43,7 @@ pub struct OpenRouterLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, available_models: Vec, fetch_models_task: Option>>, @@ -53,16 +55,26 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenRouterLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenRouterLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.spawn(async move |this, cx| { let result = task.await; @@ -114,7 +126,11 @@ impl State { } impl OpenRouterLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::({ let mut last_settings = OpenRouterLanguageModelProvider::settings(cx).clone(); @@ -131,6 +147,7 @@ impl OpenRouterLanguageModelProvider { .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, http_client: http_client.clone(), available_models: Vec::new(), fetch_models_task: None, diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index f3953f3cafa4a1f59ff86004628c0a4022f6257e..aae3a552544ebf2cc59255da954d84cf7b78c7da 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -43,6 +44,7 @@ pub struct OpenCodeLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -51,30 +53,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenCodeLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenCodeLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl OpenCodeLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index b71da5b7db05710ee30115ab54379c9ee4e4c750..cedbc9c3cb988375b90864ceb23a3b14fc50abdd 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -38,6 +39,7 @@ pub struct VercelLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -46,30 +48,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl VercelLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 78f900de0c94fd3bbbff3962e92d1a8cb9f3e118..66767edd809531b4b020263654922d742a1a04be 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http}; @@ -41,6 +42,7 @@ pub struct VercelAiGatewayLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, available_models: Vec, fetch_models_task: Option>>, @@ -52,16 +54,26 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.spawn(async move |this, cx| { let result = task.await; @@ -100,7 +112,11 @@ impl State { } impl VercelAiGatewayLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::({ let mut last_settings = VercelAiGatewayLanguageModelProvider::settings(cx).clone(); @@ -116,6 +132,7 @@ impl VercelAiGatewayLanguageModelProvider { .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, http_client: http_client.clone(), available_models: Vec::new(), fetch_models_task: None, diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index c00637bce7e67b624f5cdcae9aebe43fb43971f8..88189864c7b4b650a24afb2b872c1d6105cf9782 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; use http_client::HttpClient; @@ -39,6 +40,7 @@ pub struct XAiLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -47,30 +49,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = XAiLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = XAiLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl XAiLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index aec0cad5b1cf4be043ca21298995b08ceb93f3f2..763cdf76dab46a7fc1c233eda84cfb4ab50e6975 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -420,7 +420,10 @@ impl HighlightsTreeView { let highlight_maps: Vec<_> = grammars.iter().map(|g| g.highlight_map()).collect(); for capture in captures { - let highlight_id = highlight_maps[capture.grammar_index].get(capture.index); + let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index) + else { + continue; + }; let Some(style) = syntax_theme.get(highlight_id).cloned() else { continue; }; diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index ff1ec56b41ccf12ce6e497c21439aea5c97c3d39..97f0676d250cac2cee54b307e7c07d894d3d3128 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -880,6 +880,7 @@ impl SearchableItem for LspLogView { // LSP log is read-only. replacement: false, selection: false, + select_all: true, } } fn active_match_index( diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 3bb8826d555308145847d47525cba9de84a6aa89..d92c1392c128ed72b6e2972bc54dcf7dfc152b1e 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -1542,10 +1542,10 @@ mod tests { "await.as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(), 6..18, vec![ - (6..18, HighlightId(2)), - (20..23, HighlightId(1)), - (33..40, HighlightId(0)), - (45..46, HighlightId(0)) + (6..18, HighlightId::new(2)), + (20..23, HighlightId::new(1)), + (33..40, HighlightId::new(0)), + (45..46, HighlightId::new(0)) ], )) ); @@ -1572,12 +1572,12 @@ mod tests { "pub fn as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(), 7..19, vec![ - (0..3, HighlightId(1)), - (4..6, HighlightId(1)), - (7..19, HighlightId(2)), - (21..24, HighlightId(1)), - (34..41, HighlightId(0)), - (46..47, HighlightId(0)) + (0..3, HighlightId::new(1)), + (4..6, HighlightId::new(1)), + (7..19, HighlightId::new(2)), + (21..24, HighlightId::new(1)), + (34..41, HighlightId::new(0)), + (46..47, HighlightId::new(0)) ], )) ); @@ -1598,7 +1598,7 @@ mod tests { Some(CodeLabel::new( "inner_value: String".to_string(), 6..11, - vec![(0..11, HighlightId(3)), (13..19, HighlightId(0))], + vec![(0..11, HighlightId::new(3)), (13..19, HighlightId::new(0))], )) ); @@ -1625,8 +1625,8 @@ mod tests { vec![ (10..13, HighlightId::TABSTOP_INSERT_ID), (16..19, HighlightId::TABSTOP_INSERT_ID), - (0..7, HighlightId(2)), - (7..8, HighlightId(2)), + (0..7, HighlightId::new(2)), + (7..8, HighlightId::new(2)), ], )) ); @@ -1653,8 +1653,8 @@ mod tests { 0..4, vec![ (5..9, HighlightId::TABSTOP_REPLACE_ID), - (0..3, HighlightId(2)), - (3..4, HighlightId(2)), + (0..3, HighlightId::new(2)), + (3..4, HighlightId::new(2)), ], )) ); @@ -1682,8 +1682,8 @@ mod tests { vec![ (7..10, HighlightId::TABSTOP_REPLACE_ID), (13..16, HighlightId::TABSTOP_INSERT_ID), - (0..2, HighlightId(1)), - (3..6, HighlightId(1)), + (0..2, HighlightId::new(1)), + (3..6, HighlightId::new(1)), ], )) ); @@ -1711,8 +1711,8 @@ mod tests { vec![ (4..8, HighlightId::TABSTOP_REPLACE_ID), (12..16, HighlightId::TABSTOP_REPLACE_ID), - (0..3, HighlightId(1)), - (9..11, HighlightId(1)), + (0..3, HighlightId::new(1)), + (9..11, HighlightId::new(1)), ], )) ); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index c31ca79e7581926e7696fa596aaccc9371512841..247c082d223005a7e0bd6d57696751ce76cc4d86 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -154,6 +154,8 @@ impl MarkdownStyle { base_text_style: text_style.clone(), syntax: cx.theme().syntax().clone(), selection_background_color: colors.element_selection_background, + rule_color: colors.border, + block_quote_border_color: colors.border, code_block_overflow_x_scroll: true, heading_level_styles: Some(HeadingLevelStyles { h1: Some(TextStyleRefinement { @@ -261,6 +263,8 @@ pub struct Markdown { copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_selected_text: Option, + search_highlights: Vec>, + active_search_highlight: Option, } #[derive(Clone, Copy, Default)] @@ -314,6 +318,78 @@ actions!( ] ); +enum EscapeAction { + PassThrough, + Nbsp(usize), + DoubleNewline, + PrefixBackslash, +} + +impl EscapeAction { + fn output_len(&self) -> usize { + match self { + Self::PassThrough => 1, + Self::Nbsp(count) => count * '\u{00A0}'.len_utf8(), + Self::DoubleNewline => 2, + Self::PrefixBackslash => 2, + } + } + + fn write_to(&self, c: char, output: &mut String) { + match self { + Self::PassThrough => output.push(c), + Self::Nbsp(count) => { + for _ in 0..*count { + output.push('\u{00A0}'); + } + } + Self::DoubleNewline => { + output.push('\n'); + output.push('\n'); + } + Self::PrefixBackslash => { + // '\\' is a single backslash in Rust, e.g. '|' -> '\|' + output.push('\\'); + output.push(c); + } + } + } +} + +// Valid to operate on raw bytes since multi-byte UTF-8 +// sequences never contain ASCII-range bytes. +struct MarkdownEscaper { + in_leading_whitespace: bool, +} + +impl MarkdownEscaper { + const TAB_SIZE: usize = 4; + + fn new() -> Self { + Self { + in_leading_whitespace: true, + } + } + + fn next(&mut self, byte: u8) -> EscapeAction { + let action = if self.in_leading_whitespace && byte == b'\t' { + EscapeAction::Nbsp(Self::TAB_SIZE) + } else if self.in_leading_whitespace && byte == b' ' { + EscapeAction::Nbsp(1) + } else if byte == b'\n' { + EscapeAction::DoubleNewline + } else if byte.is_ascii_punctuation() { + EscapeAction::PrefixBackslash + } else { + EscapeAction::PassThrough + }; + + self.in_leading_whitespace = + byte == b'\n' || (self.in_leading_whitespace && (byte == b' ' || byte == b'\t')); + action + } +} + impl Markdown { pub fn new( source: SharedString, @@ -356,6 +432,8 @@ impl Markdown { copied_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), context_menu_selected_text: None, + search_highlights: Vec::new(), + active_search_highlight: None, }; this.parse(cx); this @@ -467,6 +545,8 @@ impl Markdown { self.autoscroll_request = None; self.pending_parse = None; self.should_reparse = false; + self.search_highlights.clear(); + self.active_search_highlight = None; // Don't clear parsed_markdown here - keep existing content visible until new parse completes self.parse(cx); } @@ -477,30 +557,21 @@ impl Markdown { } pub fn escape(s: &str) -> Cow<'_, str> { - // Valid to use bytes since multi-byte UTF-8 doesn't use ASCII chars. - let count = s - .bytes() - .filter(|c| *c == b'\n' || c.is_ascii_punctuation()) - .count(); - if count > 0 { - let mut output = String::with_capacity(s.len() + count); - let mut is_newline = false; - for c in s.chars() { - if is_newline && c == ' ' { - continue; - } - is_newline = c == '\n'; - if c == '\n' { - output.push('\n') - } else if c.is_ascii_punctuation() { - output.push('\\') - } - output.push(c) - } - output.into() - } else { - s.into() + let output_len: usize = { + let mut escaper = MarkdownEscaper::new(); + s.bytes().map(|byte| escaper.next(byte).output_len()).sum() + }; + + if output_len == s.len() { + return s.into(); } + + let mut escaper = MarkdownEscaper::new(); + let mut output = String::with_capacity(output_len); + for c in s.chars() { + escaper.next(c as u8).write_to(c, &mut output); + } + output.into() } pub fn selected_text(&self) -> Option { @@ -511,6 +582,40 @@ impl Markdown { } } + pub fn set_search_highlights( + &mut self, + highlights: Vec>, + active: Option, + cx: &mut Context, + ) { + self.search_highlights = highlights; + self.active_search_highlight = active; + cx.notify(); + } + + pub fn clear_search_highlights(&mut self, cx: &mut Context) { + if !self.search_highlights.is_empty() || self.active_search_highlight.is_some() { + self.search_highlights.clear(); + self.active_search_highlight = None; + cx.notify(); + } + } + + pub fn set_active_search_highlight(&mut self, active: Option, cx: &mut Context) { + if self.active_search_highlight != active { + self.active_search_highlight = active; + cx.notify(); + } + } + + pub fn search_highlights(&self) -> &[Range] { + &self.search_highlights + } + + pub fn active_search_highlight(&self) -> Option { + self.active_search_highlight + } + fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context) { if self.selection.end <= self.selection.start { return; @@ -1019,18 +1124,18 @@ impl MarkdownElement { builder.pop_div(); } - fn paint_selection( - &self, + fn paint_highlight_range( bounds: Bounds, + start: usize, + end: usize, + color: Hsla, rendered_text: &RenderedText, window: &mut Window, - cx: &mut App, ) { - let selection = self.markdown.read(cx).selection.clone(); - let selection_start = rendered_text.position_for_source_index(selection.start); - let selection_end = rendered_text.position_for_source_index(selection.end); + let start_pos = rendered_text.position_for_source_index(start); + let end_pos = rendered_text.position_for_source_index(end); if let Some(((start_position, start_line_height), (end_position, end_line_height))) = - selection_start.zip(selection_end) + start_pos.zip(end_pos) { if start_position.y == end_position.y { window.paint_quad(quad( @@ -1039,7 +1144,7 @@ impl MarkdownElement { point(end_position.x, end_position.y + end_line_height), ), Pixels::ZERO, - self.style.selection_background_color, + color, Edges::default(), Hsla::transparent_black(), BorderStyle::default(), @@ -1051,7 +1156,7 @@ impl MarkdownElement { point(bounds.right(), start_position.y + start_line_height), ), Pixels::ZERO, - self.style.selection_background_color, + color, Edges::default(), Hsla::transparent_black(), BorderStyle::default(), @@ -1064,7 +1169,7 @@ impl MarkdownElement { point(bounds.right(), end_position.y), ), Pixels::ZERO, - self.style.selection_background_color, + color, Edges::default(), Hsla::transparent_black(), BorderStyle::default(), @@ -1077,7 +1182,7 @@ impl MarkdownElement { point(end_position.x, end_position.y + end_line_height), ), Pixels::ZERO, - self.style.selection_background_color, + color, Edges::default(), Hsla::transparent_black(), BorderStyle::default(), @@ -1086,6 +1191,52 @@ impl MarkdownElement { } } + fn paint_selection( + &self, + bounds: Bounds, + rendered_text: &RenderedText, + window: &mut Window, + cx: &mut App, + ) { + let selection = self.markdown.read(cx).selection.clone(); + Self::paint_highlight_range( + bounds, + selection.start, + selection.end, + self.style.selection_background_color, + rendered_text, + window, + ); + } + + fn paint_search_highlights( + &self, + bounds: Bounds, + rendered_text: &RenderedText, + window: &mut Window, + cx: &mut App, + ) { + let markdown = self.markdown.read(cx); + let active_index = markdown.active_search_highlight; + let colors = cx.theme().colors(); + + for (i, highlight_range) in markdown.search_highlights.iter().enumerate() { + let color = if Some(i) == active_index { + colors.search_active_match_background + } else { + colors.search_match_background + }; + Self::paint_highlight_range( + bounds, + highlight_range.start, + highlight_range.end, + color, + rendered_text, + window, + ); + } + } + fn paint_mouse_listeners( &mut self, hitbox: &Hitbox, @@ -1890,6 +2041,7 @@ impl Element for MarkdownElement { self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx); rendered_markdown.element.paint(window, cx); + self.paint_search_highlights(bounds, &rendered_markdown.text, window, cx); self.paint_selection(bounds, &rendered_markdown.text, window, cx); } } @@ -3077,15 +3229,120 @@ mod tests { ); } + fn nbsp(n: usize) -> String { + "\u{00A0}".repeat(n) + } + + #[test] + fn test_escape_plain_text() { + assert_eq!(Markdown::escape("hello world"), "hello world"); + assert_eq!(Markdown::escape(""), ""); + assert_eq!(Markdown::escape("café ☕ naïve"), "café ☕ naïve"); + } + + #[test] + fn test_escape_punctuation() { + assert_eq!(Markdown::escape("hello `world`"), r"hello \`world\`"); + assert_eq!(Markdown::escape("a|b"), r"a\|b"); + } + + #[test] + fn test_escape_leading_spaces() { + assert_eq!(Markdown::escape(" hello"), [ (4), "hello"].concat()); + assert_eq!( + Markdown::escape(" | { a: string }"), + [ (4), r"\| \{ a\: string \}"].concat() + ); + assert_eq!( + Markdown::escape(" first\n second"), + [ (2), "first\n\n",  (2), "second"].concat() + ); + assert_eq!(Markdown::escape("hello world"), "hello world"); + } + + #[test] + fn test_escape_leading_tabs() { + assert_eq!(Markdown::escape("\thello"), [ (4), "hello"].concat()); + assert_eq!( + Markdown::escape("hello\n\t\tindented"), + ["hello\n\n",  (8), "indented"].concat() + ); + assert_eq!( + Markdown::escape(" \t hello"), + [ (1 + 4 + 1), "hello"].concat() + ); + assert_eq!(Markdown::escape("hello\tworld"), "hello\tworld"); + } + #[test] - fn test_escape() { - assert_eq!(Markdown::escape("hello `world`"), "hello \\`world\\`"); + fn test_escape_newlines() { + assert_eq!(Markdown::escape("a\nb"), "a\n\nb"); + assert_eq!(Markdown::escape("a\n\nb"), "a\n\n\n\nb"); + assert_eq!(Markdown::escape("\nhello"), "\n\nhello"); + } + + #[test] + fn test_escape_multiline_diagnostic() { assert_eq!( - Markdown::escape("hello\n cool world"), - "hello\n\ncool world" + Markdown::escape(" | { a: string }\n | { b: number }"), + [ +  (4), + r"\| \{ a\: string \}", + "\n\n", +  (4), + r"\| \{ b\: number \}", + ] + .concat() ); } + fn has_code_block(markdown: &str) -> bool { + let parsed_data = parse_markdown_with_options(markdown, false); + parsed_data + .events + .iter() + .any(|(_, event)| matches!(event, MarkdownEvent::Start(MarkdownTag::CodeBlock { .. }))) + } + + #[test] + fn test_escape_output_len_matches_precomputed() { + let cases = [ + "", + "hello world", + "hello `world`", + " hello", + " | { a: string }", + "\thello", + "hello\n\t\tindented", + " \t hello", + "hello\tworld", + "a\nb", + "a\n\nb", + "\nhello", + " | { a: string }\n | { b: number }", + "café ☕ naïve", + ]; + for input in cases { + let mut escaper = MarkdownEscaper::new(); + let precomputed: usize = input.bytes().map(|b| escaper.next(b).output_len()).sum(); + + let mut escaper = MarkdownEscaper::new(); + let mut output = String::new(); + for c in input.chars() { + escaper.next(c as u8).write_to(c, &mut output); + } + + assert_eq!(precomputed, output.len(), "length mismatch for {:?}", input); + } + } + + #[test] + fn test_escape_prevents_code_block() { + let diagnostic = " | { a: string }"; + assert!(has_code_block(diagnostic)); + assert!(!has_code_block(&Markdown::escape(diagnostic))); + } + #[track_caller] fn assert_mappings(rendered: &RenderedText, expected: Vec>) { assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch"); diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 19f1270bb91e8a7e9e660a62d8191a9d12b66641..3a07b258c5bd17ef2da02820ef2e724f7389ce13 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -21,6 +21,7 @@ gpui.workspace = true language.workspace = true log.workspace = true markdown.workspace = true +project.workspace = true settings.workspace = true theme_settings.workspace = true ui.workspace = true diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index f978fdfcce13808b58cd1d7467379c44b95e7433..3e6423b36603e247ba5da2a2166a8357701fa5cd 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,4 +1,5 @@ use std::cmp::min; +use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -16,11 +17,15 @@ use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, }; +use project::search::SearchQuery; use settings::Settings; use theme_settings::ThemeSettings; use ui::{WithScrollbar, prelude::*}; use util::normalize_path; -use workspace::item::{Item, ItemHandle}; +use workspace::item::{Item, ItemBufferKind, ItemHandle}; +use workspace::searchable::{ + Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, +}; use workspace::{OpenOptions, OpenVisible, Pane, Workspace}; use crate::{ @@ -382,6 +387,7 @@ impl MarkdownPreviewView { markdown.reset(contents, cx); }); view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx); + cx.emit(SearchEvent::MatchesInvalidated); } view.pending_update_task = None; cx.notify(); @@ -751,6 +757,7 @@ impl Focusable for MarkdownPreviewView { } impl EventEmitter<()> for MarkdownPreviewView {} +impl EventEmitter for MarkdownPreviewView {} impl Item for MarkdownPreviewView { type Event = (); @@ -775,6 +782,18 @@ impl Item for MarkdownPreviewView { } fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {} + + fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { + ItemBufferKind::Singleton + } + + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { + Some(Box::new(handle.clone())) + } } impl Render for MarkdownPreviewView { @@ -807,6 +826,140 @@ impl Render for MarkdownPreviewView { } } +impl SearchableItem for MarkdownPreviewView { + type Match = Range; + + fn supported_options(&self) -> SearchOptions { + SearchOptions { + case: true, + word: true, + regex: true, + replacement: false, + selection: false, + select_all: false, + find_in_results: false, + } + } + + fn get_matches(&self, _window: &mut Window, cx: &mut App) -> (Vec, SearchToken) { + ( + self.markdown.read(cx).search_highlights().to_vec(), + SearchToken::default(), + ) + } + + fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context) { + let had_highlights = !self.markdown.read(cx).search_highlights().is_empty(); + self.markdown.update(cx, |markdown, cx| { + markdown.clear_search_highlights(cx); + }); + if had_highlights { + cx.emit(SearchEvent::MatchesInvalidated); + } + } + + fn update_matches( + &mut self, + matches: &[Self::Match], + active_match_index: Option, + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) { + let old_highlights = self.markdown.read(cx).search_highlights(); + let changed = old_highlights != matches; + self.markdown.update(cx, |markdown, cx| { + markdown.set_search_highlights(matches.to_vec(), active_match_index, cx); + }); + if changed { + cx.emit(SearchEvent::MatchesInvalidated); + } + } + + fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context) -> String { + self.markdown.read(cx).selected_text().unwrap_or_default() + } + + fn activate_match( + &mut self, + index: usize, + matches: &[Self::Match], + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(match_range) = matches.get(index) { + let start = match_range.start; + self.markdown.update(cx, |markdown, cx| { + markdown.set_active_search_highlight(Some(index), cx); + markdown.request_autoscroll_to_source_index(start, cx); + }); + cx.emit(SearchEvent::ActiveMatchChanged); + } + } + + fn select_matches( + &mut self, + _matches: &[Self::Match], + _token: SearchToken, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn replace( + &mut self, + _: &Self::Match, + _: &SearchQuery, + _token: SearchToken, + _window: &mut Window, + _: &mut Context, + ) { + } + + fn find_matches( + &mut self, + query: Arc, + _window: &mut Window, + cx: &mut Context, + ) -> Task> { + let source = self.markdown.read(cx).source().to_string(); + cx.background_spawn(async move { query.search_str(&source) }) + } + + fn active_match_index( + &mut self, + direction: Direction, + matches: &[Self::Match], + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + if matches.is_empty() { + return None; + } + + let markdown = self.markdown.read(cx); + let current_source_index = markdown + .active_search_highlight() + .and_then(|i| markdown.search_highlights().get(i)) + .map(|m| m.start) + .or(self.active_source_index) + .unwrap_or(0); + + match direction { + Direction::Next => matches + .iter() + .position(|m| m.start >= current_source_index) + .or(Some(0)), + Direction::Prev => matches + .iter() + .rposition(|m| m.start <= current_source_index) + .or(Some(matches.len().saturating_sub(1))), + } + } +} + #[cfg(test)] mod tests { use crate::markdown_preview_view::ImageSource; diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 8e98a5ad93bdbec4aceb68ba9fff95688777d863..a54ff64af028f44adced1758933f794e9a002c5a 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1834,14 +1834,6 @@ impl MultiBuffer { cx.notify(); } - pub fn range_for_buffer(&self, buffer_id: BufferId, cx: &App) -> Option> { - let snapshot = self.read(cx); - let path_key = snapshot.path_key_index_for_buffer(buffer_id)?; - let start = Anchor::in_buffer(path_key, text::Anchor::min_for_buffer(buffer_id)); - let end = Anchor::in_buffer(path_key, text::Anchor::max_for_buffer(buffer_id)); - Some((start..end).to_point(&snapshot)) - } - // If point is at the end of the buffer, the last excerpt is returned pub fn point_to_buffer_offset( &self, @@ -4792,10 +4784,10 @@ impl MultiBufferSnapshot { let mut diff_transforms_cursor = self .diff_transforms .cursor::, OutputDimension>>(()); - diff_transforms_cursor.next(); if let Some(excerpt) = item { if !excerpt.contains(anchor, self) { + diff_transforms_cursor.seek(&excerpt_start_position, Bias::Left); return self.summary_for_excerpt_position_without_hunks( Bias::Left, excerpt_start_position, @@ -4822,9 +4814,7 @@ impl MultiBufferSnapshot { position += summary - excerpt_buffer_start; } - if diff_transforms_cursor.start().0 < position { - diff_transforms_cursor.seek_forward(&position, Bias::Left); - } + diff_transforms_cursor.seek(&position, Bias::Left); self.summary_for_anchor_with_excerpt_position( *anchor, position, @@ -4832,7 +4822,7 @@ impl MultiBufferSnapshot { &buffer_snapshot, ) } else { - diff_transforms_cursor.seek_forward(&excerpt_start_position, Bias::Left); + diff_transforms_cursor.seek(&excerpt_start_position, Bias::Left); self.summary_for_excerpt_position_without_hunks( Bias::Right, excerpt_start_position, @@ -5040,6 +5030,7 @@ impl MultiBufferSnapshot { if let Some(excerpt) = cursor.item() { let buffer_snapshot = excerpt.buffer_snapshot(self); if !excerpt.contains(&excerpt_anchor, self) { + diff_transforms_cursor.seek_forward(&excerpt_start_position, Bias::Left); let position = self.summary_for_excerpt_position_without_hunks( Bias::Left, excerpt_start_position, @@ -6740,6 +6731,13 @@ impl MultiBufferSnapshot { .graphemes(true) .count() } + + pub fn range_for_buffer(&self, buffer_id: BufferId) -> Option> { + let path_key = self.path_key_index_for_buffer(buffer_id)?; + let start = Anchor::in_buffer(path_key, text::Anchor::min_for_buffer(buffer_id)); + let end = Anchor::in_buffer(path_key, text::Anchor::max_for_buffer(buffer_id)); + Some((start..end).to_point(self)) + } } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index b0e541ed11d1e9200b22ce682cf3175fae30e8cf..bc904d1a05488ee365ebddf36c3b30accdfb9301 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -2898,10 +2898,11 @@ struct ReferenceExcerpt { struct ReferenceRegion { buffer_id: Option, range: Range, - buffer_range: Option>, + buffer_range: Range, + // if this is a deleted hunk, the main buffer anchor to which the deleted content is attached + deleted_hunk_anchor: Option, status: Option, - excerpt_range: Option>, - excerpt_path_key_index: Option, + excerpt: Option, } impl ReferenceMultibuffer { @@ -3055,7 +3056,15 @@ impl ReferenceMultibuffer { } } - fn expected_content(&self, cx: &App) -> (String, Vec, HashSet) { + fn expected_content( + &self, + cx: &App, + ) -> ( + String, + Vec, + HashSet, + Vec, + ) { use util::maybe; let mut text = String::new(); @@ -3093,12 +3102,10 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: Some(buffer_id), range: len..text.len(), - buffer_range: Some( - (offset..hunk_base_range.start).to_point(&buffer), - ), + buffer_range: (offset..hunk_base_range.start).to_point(&buffer), status: None, - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: None, }); } } @@ -3110,10 +3117,10 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: Some(buffer_id), range: len..text.len(), - buffer_range: Some(hunk_base_range.to_point(&buffer)), + buffer_range: hunk_base_range.to_point(&buffer), status: Some(DiffHunkStatus::deleted(hunk.secondary_status)), - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: None, }); } @@ -3127,10 +3134,10 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: Some(buffer_id), range: len..text.len(), - buffer_range: Some((offset..buffer_range.end).to_point(&buffer)), + buffer_range: (offset..buffer_range.end).to_point(&buffer), status: None, - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: None, }); } else { let diff = self.diffs.get(&buffer_id).unwrap().read(cx).snapshot(cx); @@ -3181,10 +3188,10 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: Some(buffer_id), range: len..text.len(), - buffer_range: Some((offset..hunk_range.start).to_point(&buffer)), + buffer_range: (offset..hunk_range.start).to_point(&buffer), status: None, - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: None, }); } @@ -3201,12 +3208,10 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: Some(base_buffer.remote_id()), range: len..text.len(), - buffer_range: Some( - hunk.diff_base_byte_range.to_point(&base_buffer), - ), + buffer_range: hunk.diff_base_byte_range.to_point(&base_buffer), status: Some(DiffHunkStatus::deleted(hunk.secondary_status)), - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: Some(hunk.buffer_range.start), }); } @@ -3221,10 +3226,10 @@ impl ReferenceMultibuffer { let region = ReferenceRegion { buffer_id: Some(buffer_id), range, - buffer_range: Some((offset..hunk_range.end).to_point(&buffer)), + buffer_range: (offset..hunk_range.end).to_point(&buffer), status: Some(DiffHunkStatus::added(hunk.secondary_status)), - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: None, }; offset = hunk_range.end; regions.push(region); @@ -3238,10 +3243,10 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: Some(buffer_id), range: len..text.len(), - buffer_range: Some((offset..buffer_range.end).to_point(&buffer)), + buffer_range: (offset..buffer_range.end).to_point(&buffer), status: None, - excerpt_range: Some(excerpt.range.clone()), - excerpt_path_key_index: Some(excerpt.path_key_index), + excerpt: Some(excerpt.clone()), + deleted_hunk_anchor: None, }); } } @@ -3251,13 +3256,16 @@ impl ReferenceMultibuffer { regions.push(ReferenceRegion { buffer_id: None, range: 0..1, - buffer_range: Some(Point::new(0, 0)..Point::new(0, 1)), + buffer_range: Point::new(0, 0)..Point::new(0, 1), status: None, - excerpt_range: None, - excerpt_path_key_index: None, + excerpt: None, + deleted_hunk_anchor: None, }); } else { text.pop(); + let region = regions.last_mut().unwrap(); + assert!(region.deleted_hunk_anchor.is_none()); + region.range.end -= 1; } // Retrieve the row info using the region that contains @@ -3268,37 +3276,38 @@ impl ReferenceMultibuffer { .map(|line| { let row_info = regions .iter() - .position(|region| region.range.contains(&ix)) + .rposition(|region| { + region.range.contains(&ix) || (ix == text.len() && ix == region.range.end) + }) .map_or(RowInfo::default(), |region_ix| { let region = regions[region_ix].clone(); - let buffer_row = region.buffer_range.as_ref().map(|buffer_range| { - buffer_range.start.row - + text[region.range.start..ix].matches('\n').count() as u32 - }); - let main_buffer = self - .excerpts - .iter() - .find(|e| e.range == region.excerpt_range.clone().unwrap()) - .map(|e| e.buffer.clone()); + let buffer_row = region.buffer_range.start.row + + text[region.range.start..ix].matches('\n').count() as u32; + let main_buffer = region.excerpt.as_ref().map(|e| e.buffer.clone()); + let excerpt_range = region.excerpt.as_ref().map(|e| &e.range); let is_excerpt_start = region_ix == 0 - || ®ions[region_ix - 1].excerpt_range != ®ion.excerpt_range + || regions[region_ix - 1].excerpt.as_ref().map(|e| &e.range) + != excerpt_range || regions[region_ix - 1].range.is_empty(); let mut is_excerpt_end = region_ix == regions.len() - 1 - || ®ions[region_ix + 1].excerpt_range != ®ion.excerpt_range; + || regions[region_ix + 1].excerpt.as_ref().map(|e| &e.range) + != excerpt_range; let is_start = !text[region.range.start..ix].contains('\n'); + let is_last_region = region_ix == regions.len() - 1; let mut is_end = if region.range.end > text.len() { !text[ix..].contains('\n') } else { - text[ix..region.range.end.min(text.len())] + let remaining_newlines = text[ix..region.range.end.min(text.len())] .matches('\n') - .count() - == 1 + .count(); + remaining_newlines == if is_last_region { 0 } else { 1 } }; if region_ix < regions.len() - 1 && !text[ix..].contains("\n") && (region.status == Some(DiffHunkStatus::added_none()) || region.status.is_some_and(|s| s.is_deleted())) - && regions[region_ix + 1].excerpt_range == region.excerpt_range + && regions[region_ix + 1].excerpt.as_ref().map(|e| &e.range) + == excerpt_range && regions[region_ix + 1].range.start == text.len() { is_end = true; @@ -3308,7 +3317,6 @@ impl ReferenceMultibuffer { MultiBufferRow(text[..ix].matches('\n').count() as u32); let mut expand_direction = None; if let Some(buffer) = &main_buffer { - let buffer_row = buffer_row.unwrap(); let needs_expand_up = is_excerpt_start && is_start && buffer_row > 0; let needs_expand_down = is_excerpt_end && is_end @@ -3326,19 +3334,18 @@ impl ReferenceMultibuffer { RowInfo { buffer_id: region.buffer_id, diff_status: region.status, - buffer_row, + buffer_row: Some(buffer_row), wrapped_buffer_row: None, multibuffer_row: Some(multibuffer_row), expand_info: maybe!({ let direction = expand_direction?; - let excerpt_range = region.excerpt_range?; - let path_key_index = region.excerpt_path_key_index?; + let excerpt = region.excerpt.as_ref()?; Some(ExpandInfo { direction, start_anchor: Anchor::in_buffer( - path_key_index, - excerpt_range.start, + excerpt.path_key_index, + excerpt.range.start, ), }) }), @@ -3349,7 +3356,7 @@ impl ReferenceMultibuffer { }) .collect(); - (text, row_infos, excerpt_boundary_rows) + (text, row_infos, excerpt_boundary_rows, regions) } fn diffs_updated(&mut self, cx: &mut App) { @@ -3414,6 +3421,95 @@ impl ReferenceMultibuffer { }) }); } + + fn anchor_to_offset(&self, anchor: &Anchor, cx: &App) -> Option { + if anchor.diff_base_anchor().is_some() { + panic!("reference multibuffer cannot yet resolve anchors inside deleted hunks"); + } + let (anchor, snapshot, path_key) = self.anchor_to_buffer_anchor(anchor, cx)?; + // TODO(cole) can maybe make this and expected content call a common function instead + let (text, _, _, regions) = self.expected_content(cx); + + // Locate the first region that contains or is past the putative location of the buffer anchor + let ix = regions.partition_point(|region| { + let excerpt = region + .excerpt + .as_ref() + .expect("should have no buffers in empty reference multibuffer"); + excerpt + .path_key + .cmp(&path_key) + .then_with(|| { + if excerpt.range.end.cmp(&anchor, &snapshot).is_lt() { + Ordering::Less + } else if excerpt.range.start.cmp(&anchor, &snapshot).is_gt() { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .then_with(|| { + if let Some(deleted_hunk_anchor) = region.deleted_hunk_anchor { + deleted_hunk_anchor.cmp(&anchor, &snapshot) + } else { + let point = anchor.to_point(&snapshot); + assert_eq!(region.buffer_id, Some(snapshot.remote_id())); + if region.buffer_range.end < point { + Ordering::Less + } else if region.buffer_range.start > point { + Ordering::Greater + } else { + Ordering::Equal + } + } + }) + .is_lt() + }); + + let Some(region) = regions.get(ix) else { + return Some(MultiBufferOffset(text.len())); + }; + + let offset = if region.buffer_id == Some(snapshot.remote_id()) { + let buffer_offset = anchor.to_offset(&snapshot); + let buffer_range = region.buffer_range.to_offset(&snapshot); + assert!(buffer_offset <= buffer_range.end); + let overshoot = buffer_offset.saturating_sub(buffer_range.start); + region.range.start + overshoot + } else { + region.range.start + }; + Some(MultiBufferOffset(offset)) + } + + fn anchor_to_buffer_anchor( + &self, + anchor: &Anchor, + cx: &App, + ) -> Option<(text::Anchor, BufferSnapshot, PathKey)> { + let (excerpt, anchor) = match anchor { + Anchor::Min => { + let excerpt = self.excerpts.first()?; + (excerpt, excerpt.range.start) + } + Anchor::Excerpt(excerpt_anchor) => ( + self.excerpts.iter().find(|excerpt| { + excerpt.buffer.read(cx).remote_id() == excerpt_anchor.buffer_id() + })?, + excerpt_anchor.text_anchor, + ), + Anchor::Max => { + let excerpt = self.excerpts.last()?; + (excerpt, excerpt.range.end) + } + }; + + Some(( + anchor, + excerpt.buffer.read(cx).snapshot(), + excerpt.path_key.clone(), + )) + } } #[gpui::test(iterations = 100)] @@ -3791,12 +3887,13 @@ fn mutate_excerpt_ranges( _ => { let end_row = rng.random_range(0..=buffer.max_point().row); let start_row = rng.random_range(0..=end_row); + let end_col = buffer.line_len(end_row); log::info!( "Inserting excerpt for buffer {:?}, row range {:?}", buffer.remote_id(), start_row..end_row ); - ranges_to_add.push(Point::new(start_row, 0)..Point::new(end_row, 0)); + ranges_to_add.push(Point::new(start_row, 0)..Point::new(end_row, end_col)); } } } @@ -3820,8 +3917,36 @@ fn check_multibuffer( .collect::>(); let actual_row_infos = snapshot.row_infos(MultiBufferRow(0)).collect::>(); - let (expected_text, expected_row_infos, expected_boundary_rows) = + let anchors_to_check = anchors + .iter() + .filter_map(|anchor| { + snapshot + .anchor_to_buffer_anchor(*anchor) + .map(|(anchor, _)| anchor) + }) + // Intentionally mix in some anchors that are (in general) not contained in any excerpt + .chain( + reference + .excerpts + .iter() + .map(|excerpt| excerpt.buffer.read(cx).remote_id()) + .dedup() + .flat_map(|buffer_id| { + [ + text::Anchor::min_for_buffer(buffer_id), + text::Anchor::max_for_buffer(buffer_id), + ] + }), + ) + .map(|anchor| snapshot.anchor_in_buffer(anchor).unwrap()) + .collect::>(); + + let (expected_text, expected_row_infos, expected_boundary_rows, _) = reference.expected_content(cx); + let expected_anchor_offsets = anchors_to_check + .iter() + .map(|anchor| reference.anchor_to_offset(anchor, cx).unwrap()) + .collect::>(); let has_diff = actual_row_infos .iter() @@ -3949,6 +4074,15 @@ fn check_multibuffer( ); } + let actual_anchor_offsets = anchors_to_check + .into_iter() + .map(|anchor| anchor.to_offset(&snapshot)) + .collect::>(); + assert_eq!( + actual_anchor_offsets, expected_anchor_offsets, + "buffer anchor resolves to wrong offset" + ); + for _ in 0..10 { let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right); assert_eq!( @@ -5911,3 +6045,104 @@ fn test_cannot_seek_backward_after_excerpt_replacement(cx: &mut TestAppContext) snapshot.summaries_for_anchors::(&[anchor_in_e_b2, anchor_in_e_b3]); }); } + +#[gpui::test] +fn test_resolving_max_anchor_for_buffer(cx: &mut TestAppContext) { + let dock_base_text = indoc! {" + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + "}; + + let dock_text = indoc! {" + 0 + 4 + 5 + 6 + 10 + 11 + 12 + "}; + + let dock_buffer = cx.new(|cx| Buffer::local(dock_text, cx)); + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text(dock_base_text, &dock_buffer.read(cx).snapshot(), cx) + }); + + let workspace_text = "second buffer\n"; + let workspace_buffer = cx.new(|cx| Buffer::local(workspace_text, cx)); + + let dock_path = PathKey::with_sort_prefix(0, rel_path("").into_arc()); + let workspace_path = PathKey::with_sort_prefix(1, rel_path("").into_arc()); + + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpt_ranges_for_path( + dock_path, + dock_buffer.clone(), + &dock_buffer.read(cx).snapshot(), + vec![ + ExcerptRange::new(Point::zero()..Point::new(1, 1)), + ExcerptRange::new(Point::new(3, 0)..Point::new(4, 2)), + ], + cx, + ); + multibuffer.set_excerpt_ranges_for_path( + workspace_path, + workspace_buffer.clone(), + &workspace_buffer.read(cx).snapshot(), + vec![ExcerptRange::new( + Point::zero()..workspace_buffer.read(cx).max_point(), + )], + cx, + ); + multibuffer.add_diff(diff, cx); + multibuffer.set_all_diff_hunks_expanded(cx); + }); + + let snapshot = multibuffer.update(cx, |multibuffer, cx| multibuffer.snapshot(cx)); + let diff = format_diff( + &snapshot.text(), + &snapshot.row_infos(MultiBufferRow(0)).collect::>(), + &Default::default(), + None, + ); + assert_eq!( + diff, + indoc! {" + 0 + - 1 + - 2 + - 3 + 4 [↓] + 6 [↑] + - 7 + - 8 + - 9 + 10 [↓] + second buffer + "} + ); + + multibuffer.update(cx, |multibuffer, cx| { + let snapshot = multibuffer.snapshot(cx); + let point = snapshot + .anchor_in_buffer(text::Anchor::max_for_buffer( + dock_buffer.read(cx).remote_id(), + )) + .unwrap() + .to_point(&snapshot); + assert_eq!(point, Point::new(10, 0)); + }) +} diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index ccffbd29f4bd03b0d4bb0a070f4229a517597468..cd037786a399eb979fd5d9053c57efe3100dd473 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -98,6 +98,7 @@ watch.workspace = true wax.workspace = true which.workspace = true worktree.workspace = true +zed_credentials_provider.workspace = true zeroize.workspace = true zlog.workspace = true ztracing.workspace = true diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 395056384a79d39c978e14643166148685ea0b90..7b9fc16f10022805ea62df2f8b3df279fc96ae3d 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -684,7 +684,7 @@ impl ContextServerStore { let server_url = url.clone(); let id = id.clone(); cx.spawn(async move |_this, cx| { - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await { log::warn!("{} failed to clear OAuth session on removal: {}", id, err); @@ -797,8 +797,7 @@ impl ContextServerStore { if configuration.has_static_auth_header() { None } else { - let credentials_provider = - cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); let http_client = cx.update(|cx| cx.http_client()); match Self::load_session(&credentials_provider, url, &cx).await { @@ -1070,7 +1069,7 @@ impl ContextServerStore { .context("Failed to start OAuth callback server")?; let http_client = cx.update(|cx| cx.http_client()); - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); let server_url = match configuration.as_ref() { ContextServerConfiguration::Http { url, .. } => url.clone(), _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), @@ -1233,7 +1232,7 @@ impl ContextServerStore { self.stop_server(&id, cx)?; cx.spawn(async move |this, cx| { - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await { log::error!("{} failed to clear OAuth session: {}", id, err); } @@ -1451,7 +1450,7 @@ async fn resolve_start_failure( // (e.g. timeout because the server rejected the token silently). Clear it // so the next start attempt can get a clean 401 and trigger the auth flow. if www_authenticate.is_none() { - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); match ContextServerStore::load_session(&credentials_provider, &server_url, cx).await { Ok(Some(_)) => { log::info!("{id} start failed with a cached OAuth session present; clearing it"); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 6f838f02768a38d1c84935f5a7e7a303e682847d..6bc7f1ab52db8665efac7ab5631986b5ec0c8e33 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -287,6 +287,7 @@ pub struct RepositorySnapshot { pub original_repo_abs_path: Arc, pub path_style: PathStyle, pub branch: Option, + pub branch_list: Arc<[Branch]>, pub head_commit: Option, pub scan_id: u64, pub merge: MergeDetails, @@ -428,7 +429,8 @@ pub enum GitGraphEvent { #[derive(Clone, Debug, PartialEq, Eq)] pub enum RepositoryEvent { StatusesChanged, - BranchChanged, + HeadChanged, + BranchListChanged, StashEntriesChanged, GitWorktreeListChanged, PendingOpsChanged { pending_ops: SumTree }, @@ -560,6 +562,10 @@ impl GitStore { client.add_entity_request_handler(Self::handle_run_hook); client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_show); + client.add_entity_request_handler(Self::handle_create_checkpoint); + client.add_entity_request_handler(Self::handle_restore_checkpoint); + client.add_entity_request_handler(Self::handle_compare_checkpoints); + client.add_entity_request_handler(Self::handle_diff_checkpoints); client.add_entity_request_handler(Self::handle_load_commit_diff); client.add_entity_request_handler(Self::handle_file_history); client.add_entity_request_handler(Self::handle_checkout_files); @@ -2619,6 +2625,92 @@ impl GitStore { }) } + async fn handle_create_checkpoint( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + let checkpoint = repository_handle + .update(&mut cx, |repository, _| repository.checkpoint()) + .await??; + + Ok(proto::GitCreateCheckpointResponse { + commit_sha: checkpoint.commit_sha.as_bytes().to_vec(), + }) + } + + async fn handle_restore_checkpoint( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + let checkpoint = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.commit_sha)?, + }; + + repository_handle + .update(&mut cx, |repository, _| { + repository.restore_checkpoint(checkpoint) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_compare_checkpoints( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + let left = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.left_commit_sha)?, + }; + let right = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.right_commit_sha)?, + }; + + let equal = repository_handle + .update(&mut cx, |repository, _| { + repository.compare_checkpoints(left, right) + }) + .await??; + + Ok(proto::GitCompareCheckpointsResponse { equal }) + } + + async fn handle_diff_checkpoints( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + let base = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.base_commit_sha)?, + }; + let target = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.target_commit_sha)?, + }; + + let diff = repository_handle + .update(&mut cx, |repository, _| { + repository.diff_checkpoints(base, target) + }) + .await??; + + Ok(proto::GitDiffCheckpointsResponse { diff }) + } + async fn handle_load_commit_diff( this: Entity, envelope: TypedEnvelope, @@ -3596,6 +3688,7 @@ impl RepositorySnapshot { .unwrap_or_else(|| work_directory_abs_path.clone()), work_directory_abs_path, branch: None, + branch_list: Arc::from([]), head_commit: None, scan_id: 0, merge: Default::default(), @@ -3958,11 +4051,17 @@ impl Repository { .shared(); cx.subscribe_self(move |this, event: &RepositoryEvent, _| match event { - RepositoryEvent::BranchChanged => { + RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => { if this.scan_id > 1 { this.initial_graph_data.clear(); } } + RepositoryEvent::StashEntriesChanged => { + if this.scan_id > 1 { + this.initial_graph_data + .retain(|(log_source, _), _| *log_source != LogSource::All); + } + } _ => {} }) .detach(); @@ -5504,7 +5603,7 @@ impl Repository { log::info!("head branch after scan is {branch:?}"); let snapshot = this.update(&mut cx, |this, cx| { this.snapshot.branch = branch; - cx.emit(RepositoryEvent::BranchChanged); + cx.emit(RepositoryEvent::HeadChanged); this.snapshot.clone() })?; if let Some(updates_tx) = updates_tx { @@ -6229,12 +6328,24 @@ impl Repository { } pub fn checkpoint(&mut self) -> oneshot::Receiver> { - self.send_job(None, |repo, _cx| async move { + let id = self.id; + self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.checkpoint().await } - RepositoryState::Remote(..) => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitCreateCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await?; + + Ok(GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&response.commit_sha)?, + }) + } } }) } @@ -6243,12 +6354,22 @@ impl Repository { &mut self, checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { + let id = self.id; self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.restore_checkpoint(checkpoint).await } - RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRestoreCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + commit_sha: checkpoint.commit_sha.as_bytes().to_vec(), + }) + .await?; + Ok(()) + } } }) } @@ -6268,7 +6389,7 @@ impl Repository { .as_ref() .map(proto_to_commit_details); if self.snapshot.branch != new_branch || self.snapshot.head_commit != new_head_commit { - cx.emit(RepositoryEvent::BranchChanged) + cx.emit(RepositoryEvent::HeadChanged) } self.snapshot.branch = new_branch; self.snapshot.head_commit = new_head_commit; @@ -6342,12 +6463,23 @@ impl Repository { left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { + let id = self.id; self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.compare_checkpoints(left, right).await } - RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitCompareCheckpoints { + project_id: project_id.0, + repository_id: id.to_proto(), + left_commit_sha: left.commit_sha.as_bytes().to_vec(), + right_commit_sha: right.commit_sha.as_bytes().to_vec(), + }) + .await?; + Ok(response.equal) + } } }) } @@ -6357,6 +6489,7 @@ impl Repository { base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { + let id = self.id; self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { @@ -6364,7 +6497,17 @@ impl Repository { .diff_checkpoints(base_checkpoint, target_checkpoint) .await } - RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitDiffCheckpoints { + project_id: project_id.0, + repository_id: id.to_proto(), + base_commit_sha: base_checkpoint.commit_sha.as_bytes().to_vec(), + target_commit_sha: target_checkpoint.commit_sha.as_bytes().to_vec(), + }) + .await?; + Ok(response.diff) + } } }) } @@ -6447,7 +6590,7 @@ impl Repository { let state = RepositoryState::Local(state); let mut jobs = VecDeque::new(); loop { - while let Ok(Some(next_job)) = job_rx.try_next() { + while let Ok(next_job) = job_rx.try_recv() { jobs.push_back(next_job); } @@ -6483,7 +6626,7 @@ impl Repository { let state = RepositoryState::Remote(state); let mut jobs = VecDeque::new(); loop { - while let Ok(Some(next_job)) = job_rx.try_next() { + while let Ok(next_job) = job_rx.try_recv() { jobs.push_back(next_job); } @@ -7184,7 +7327,8 @@ async fn compute_snapshot( } }) .await?; - let branch = branches.into_iter().find(|branch| branch.is_head); + let branch = branches.iter().find(|branch| branch.is_head).cloned(); + let branch_list: Arc<[Branch]> = branches.into(); let linked_worktrees: Arc<[GitWorktree]> = all_worktrees .into_iter() @@ -7207,14 +7351,16 @@ async fn compute_snapshot( .await?; let snapshot = this.update(cx, |this, cx| { - let branch_changed = + let head_changed = branch != this.snapshot.branch || head_commit != this.snapshot.head_commit; + let branch_list_changed = *branch_list != *this.snapshot.branch_list; let worktrees_changed = *linked_worktrees != *this.snapshot.linked_worktrees; this.snapshot = RepositorySnapshot { id, work_directory_abs_path, branch, + branch_list: branch_list.clone(), head_commit, remote_origin_url, remote_upstream_url, @@ -7223,8 +7369,12 @@ async fn compute_snapshot( ..prev_snapshot }; - if branch_changed { - cx.emit(RepositoryEvent::BranchChanged); + if head_changed { + cx.emit(RepositoryEvent::HeadChanged); + } + + if branch_list_changed { + cx.emit(RepositoryEvent::BranchListChanged); } if worktrees_changed { diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/branch_diff.rs index 3b8324fce8ffea7049838aeac09e831463dbd34e..dc7c8bf647585d9fcf1d5f92e0e976f86939a781 100644 --- a/crates/project/src/git_store/branch_diff.rs +++ b/crates/project/src/git_store/branch_diff.rs @@ -70,7 +70,7 @@ impl BranchDiff { } GitStoreEvent::RepositoryUpdated( event_repo_id, - RepositoryEvent::StatusesChanged | RepositoryEvent::BranchChanged, + RepositoryEvent::StatusesChanged | RepositoryEvent::HeadChanged, _, ) => this .repo diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 286d3a85f86173bff5d17d8d7c86d26464a04714..2f579f5a724db143bbd4b0f9853a217bd6b14655 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4414,7 +4414,8 @@ impl LspStore { } worktree::Event::UpdatedGitRepositories(_) | worktree::Event::DeletedEntry(_) - | worktree::Event::Deleted => {} + | worktree::Event::Deleted + | worktree::Event::UpdatedRootRepoCommonDir => {} }) .detach() } diff --git a/crates/project/src/manifest_tree.rs b/crates/project/src/manifest_tree.rs index 1ae5b0e809f3803c3f8858afb065637ba0a0f256..fb1b7e96e4a20370493e0837360a28583ffbbfc0 100644 --- a/crates/project/src/manifest_tree.rs +++ b/crates/project/src/manifest_tree.rs @@ -59,7 +59,7 @@ impl WorktreeRoots { let path = TriePath::from(entry.path.as_ref()); this.roots.remove(&path); } - WorktreeEvent::Deleted => {} + WorktreeEvent::Deleted | WorktreeEvent::UpdatedRootRepoCommonDir => {} } }), }) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index bbfa7ffe208c198e76a9838695765c912977385d..0ec3366ca8f9f6c6e4e3cbd411e1894de4d0f2b8 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2349,6 +2349,22 @@ impl Project { .find(|tree| tree.read(cx).root_name() == root_name) } + pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey { + let roots = self + .visible_worktrees(cx) + .map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + snapshot + .root_repo_common_dir() + .and_then(|dir| Some(dir.parent()?.to_path_buf())) + .unwrap_or(snapshot.abs_path().to_path_buf()) + }) + .collect::>(); + let host = self.remote_connection_options(cx); + let path_list = PathList::new(&roots); + ProjectGroupKey::new(host, path_list) + } + #[inline] pub fn worktree_root_names<'a>(&'a self, cx: &'a App) -> impl Iterator { self.visible_worktrees(cx) @@ -4747,6 +4763,19 @@ impl Project { }); } + pub fn remove_worktree_for_main_worktree_path( + &mut self, + path: impl AsRef, + cx: &mut Context, + ) { + let path = path.as_ref(); + self.worktree_store.update(cx, |worktree_store, cx| { + if let Some(worktree) = worktree_store.worktree_for_main_worktree_path(path, cx) { + worktree_store.remove_worktree(worktree.read(cx).id(), cx); + } + }); + } + fn add_worktree(&mut self, worktree: &Entity, cx: &mut Context) { self.worktree_store.update(cx, |worktree_store, cx| { worktree_store.add(worktree, cx); @@ -6018,6 +6047,49 @@ impl Project { } } +/// Identifies a project group by a set of paths the workspaces in this group +/// have. +/// +/// Paths are mapped to their main worktree path first so we can group +/// workspaces by main repos. +#[derive(PartialEq, Eq, Hash, Clone, Debug)] +pub struct ProjectGroupKey { + paths: PathList, + host: Option, +} + +impl ProjectGroupKey { + /// Creates a new `ProjectGroupKey` with the given path list. + /// + /// The path list should point to the git main worktree paths for a project. + pub fn new(host: Option, paths: PathList) -> Self { + Self { paths, host } + } + + pub fn display_name(&self) -> SharedString { + let mut names = Vec::with_capacity(self.paths.paths().len()); + for abs_path in self.paths.paths() { + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); + } + } + if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() + } + } + + pub fn path_list(&self) -> &PathList { + &self.paths + } + + pub fn host(&self) -> Option { + self.host.clone() + } +} + pub struct PathMatchCandidateSet { pub snapshot: Snapshot, pub include_ignored: bool, diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 3a554eb3da1557849e18846b09a7787ab939f46d..cd4702d04863c2fc3026700b2d6653e1db24dbff 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -620,4 +620,56 @@ impl SearchQuery { Self::Text { .. } => None, } } + + pub fn search_str(&self, text: &str) -> Vec> { + if self.as_str().is_empty() { + return Vec::new(); + } + + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + + let mut matches = Vec::new(); + match self { + Self::Text { + search, whole_word, .. + } => { + for mat in search.find_iter(text.as_bytes()) { + if *whole_word { + let prev_char = text[..mat.start()].chars().last(); + let next_char = text[mat.end()..].chars().next(); + if prev_char.is_some_and(&is_word_char) + || next_char.is_some_and(&is_word_char) + { + continue; + } + } + matches.push(mat.start()..mat.end()); + } + } + Self::Regex { + regex, + multiline, + one_match_per_line, + .. + } => { + if *multiline { + for mat in regex.find_iter(text).flatten() { + matches.push(mat.start()..mat.end()); + } + } else { + let mut line_offset = 0; + for line in text.split('\n') { + for mat in regex.find_iter(line).flatten() { + matches.push((line_offset + mat.start())..(line_offset + mat.end())); + if *one_match_per_line { + break; + } + } + line_offset += line.len() + 1; + } + } + } + } + matches + } } diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 92f7db453a81c6224455002b7811f2e6945f2a82..7ca721ddb50c3f216ed630665e547b60ce4d52bf 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -812,6 +812,7 @@ impl WorktreeStore { // The worktree root itself has been deleted (for single-file worktrees) // The worktree will be removed via the observe_release callback } + worktree::Event::UpdatedRootRepoCommonDir => {} } }) .detach(); @@ -849,6 +850,21 @@ impl WorktreeStore { self.send_project_updates(cx); } + pub fn worktree_for_main_worktree_path( + &self, + path: &Path, + cx: &App, + ) -> Option> { + self.visible_worktrees(cx).find(|worktree| { + let worktree = worktree.read(cx); + if let Some(common_dir) = worktree.root_repo_common_dir() { + common_dir.parent() == Some(path) + } else { + worktree.abs_path().as_ref() == path + } + }) + } + pub fn set_worktrees_reordered(&mut self, worktrees_reordered: bool) { self.worktrees_reordered = worktrees_reordered; } diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index 91d5ca1697255a07c0bc9bb37869d87773792297..7d266ff1365485032458d6de033b57f106602869 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -43,7 +43,7 @@ fn test_multi_len_chars_normalization() { let mut label = CodeLabel::new( "myElˇ (parameter) myElˇ: {\n foo: string;\n}".to_string(), 0..6, - vec![(0..6, HighlightId(1))], + vec![(0..6, HighlightId::new(1))], ); ensure_uniform_list_compatible_label(&mut label); assert_eq!( @@ -51,7 +51,7 @@ fn test_multi_len_chars_normalization() { CodeLabel::new( "myElˇ (parameter) myElˇ: { foo: string; }".to_string(), 0..6, - vec![(0..6, HighlightId(1))], + vec![(0..6, HighlightId::new(1))], ) ); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 6601b0744aa770917390e03b16ae93d3bc7f637f..d6c2ce37c9e60e17bd43c3f6c3ad10cde52b4bec 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -4448,7 +4448,7 @@ async fn test_definition(cx: &mut gpui::TestAppContext) { // Assert no new language server started cx.executor().run_until_parked(); - assert!(fake_servers.try_next().is_err()); + assert!(fake_servers.try_recv().is_err()); assert_eq!(definitions.len(), 1); let definition = definitions.pop().unwrap(); @@ -11161,7 +11161,7 @@ async fn test_odd_events_for_ignored_dirs( assert_eq!( repository_updates.lock().drain(..).collect::>(), vec![ - RepositoryEvent::BranchChanged, + RepositoryEvent::HeadChanged, RepositoryEvent::StatusesChanged, RepositoryEvent::StatusesChanged, ], diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e9062364fc73ed6e266e3f8904be51eaaf5b6535..c2f1bb7131ad31ea75aee84bad17b7971d489a09 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -7126,7 +7126,7 @@ impl Render for ProjectPanel { .workspace .update(cx, |workspace, cx| { workspace.open_workspace_for_paths( - OpenMode::Replace, + OpenMode::Activate, external_paths.paths().to_owned(), window, cx, diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 84b92f3eaa4f0216b881526b3aac42f8980ffe78..931e332d93d869bc31909643190d5b35f32409dc 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -140,11 +140,20 @@ impl PickerDelegate for ProjectSymbolsDelegate { ); editor.update(cx, |editor, cx| { + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let Some(buffer_snapshot) = multibuffer_snapshot.as_singleton() else { + return; + }; + let text_anchor = buffer_snapshot.anchor_before(position); + let Some(anchor) = multibuffer_snapshot.anchor_in_buffer(text_anchor) + else { + return; + }; editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, - |s| s.select_ranges([position..position]), + |s| s.select_ranges([anchor..anchor]), ); }); })?; diff --git a/crates/proto/proto/call.proto b/crates/proto/proto/call.proto index aa964c64cd04db71a71ac081e034be10cbf95048..71351fb74c5834fe0b1650f22e851c21cd752466 100644 --- a/crates/proto/proto/call.proto +++ b/crates/proto/proto/call.proto @@ -225,6 +225,7 @@ message UpdateWorktree { uint64 scan_id = 8; bool is_last_update = 9; string abs_path = 10; + optional string root_repo_common_dir = 11; } // deprecated diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index cb878cade726002e7e09670cf7c190880d8e66cb..0cbb635d78dddc81aa7c75340f2fbebe83a474e3 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -586,6 +586,43 @@ message GitCreateWorktree { optional string commit = 5; } +message GitCreateCheckpoint { + uint64 project_id = 1; + uint64 repository_id = 2; +} + +message GitCreateCheckpointResponse { + bytes commit_sha = 1; +} + +message GitRestoreCheckpoint { + uint64 project_id = 1; + uint64 repository_id = 2; + bytes commit_sha = 3; +} + +message GitCompareCheckpoints { + uint64 project_id = 1; + uint64 repository_id = 2; + bytes left_commit_sha = 3; + bytes right_commit_sha = 4; +} + +message GitCompareCheckpointsResponse { + bool equal = 1; +} + +message GitDiffCheckpoints { + uint64 project_id = 1; + uint64 repository_id = 2; + bytes base_commit_sha = 3; + bytes target_commit_sha = 4; +} + +message GitDiffCheckpointsResponse { + string diff = 1; +} + message GitRemoveWorktree { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index d165bcb9529a41294d2bc25572f454c425f8c3f0..24e7c5372f2679eab1726487e1967edcef6024ed 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -467,7 +467,14 @@ message Envelope { SpawnKernelResponse spawn_kernel_response = 427; KillKernel kill_kernel = 428; GitRemoveWorktree git_remove_worktree = 431; - GitRenameWorktree git_rename_worktree = 432; // current max + GitRenameWorktree git_rename_worktree = 432; + GitCreateCheckpoint git_create_checkpoint = 433; + GitCreateCheckpointResponse git_create_checkpoint_response = 434; + GitRestoreCheckpoint git_restore_checkpoint = 435; + GitCompareCheckpoints git_compare_checkpoints = 436; + GitCompareCheckpointsResponse git_compare_checkpoints_response = 437; + GitDiffCheckpoints git_diff_checkpoints = 438; + GitDiffCheckpointsResponse git_diff_checkpoints_response = 439; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 8c72fa08c57755dc45b9658db441a037d0a9fe2e..dd77d2a2da8d4dbc2c0f91f63cb59dd1591ee3f4 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -294,6 +294,13 @@ messages!( (GitCommitDetails, Background), (GitFileHistory, Background), (GitFileHistoryResponse, Background), + (GitCreateCheckpoint, Background), + (GitCreateCheckpointResponse, Background), + (GitRestoreCheckpoint, Background), + (GitCompareCheckpoints, Background), + (GitCompareCheckpointsResponse, Background), + (GitDiffCheckpoints, Background), + (GitDiffCheckpointsResponse, Background), (SetIndexText, Background), (Push, Background), (Fetch, Background), @@ -514,6 +521,10 @@ request_messages!( (RegisterBufferWithLanguageServers, Ack), (GitShow, GitCommitDetails), (GitFileHistory, GitFileHistoryResponse), + (GitCreateCheckpoint, GitCreateCheckpointResponse), + (GitRestoreCheckpoint, Ack), + (GitCompareCheckpoints, GitCompareCheckpointsResponse), + (GitDiffCheckpoints, GitDiffCheckpointsResponse), (GitReset, Ack), (GitDeleteBranch, Ack), (GitCheckoutFiles, Ack), @@ -696,6 +707,10 @@ entity_messages!( RegisterBufferWithLanguageServers, GitShow, GitFileHistory, + GitCreateCheckpoint, + GitRestoreCheckpoint, + GitCompareCheckpoints, + GitDiffCheckpoints, GitReset, GitDeleteBranch, GitCheckoutFiles, @@ -866,6 +881,7 @@ pub fn split_worktree_update(mut message: UpdateWorktree) -> impl Iterator String { } pub fn suggest_on_worktree_updated( + workspace: &mut Workspace, worktree_id: WorktreeId, updated_entries: &UpdatedEntriesSet, project: &gpui::Entity, window: &mut Window, cx: &mut Context, ) { + let cli_auto_open = workspace.open_in_dev_container(); + let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| { path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path() }); - if !devcontainer_updated { + if !devcontainer_updated && !cli_auto_open { return; } @@ -54,7 +57,35 @@ pub fn suggest_on_worktree_updated( return; } - if find_configs_in_snapshot(worktree).is_empty() { + let has_configs = !find_configs_in_snapshot(worktree).is_empty(); + + if cli_auto_open { + workspace.set_open_in_dev_container(false); + let task = cx.spawn_in(window, async move |workspace, cx| { + let scans_complete = + workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?; + scans_complete.await; + + workspace.update_in(cx, |workspace, window, cx| { + let has_configs = workspace + .project() + .read(cx) + .worktrees(cx) + .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty()); + if has_configs { + cx.on_next_frame(window, move |_workspace, window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + }); + } else { + log::warn!("--dev-container: no devcontainer configuration found in project"); + } + }) + }); + workspace.set_dev_container_task(task); + return; + } + + if !has_configs { return; } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index e1a0cb0609a9883bfe73048eda64cc8d1b299c2e..24010017ff9fa4eb62a1787332fed70f740ccc2d 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -475,11 +475,12 @@ pub fn init(cx: &mut App) { cx.subscribe_in( workspace.project(), window, - move |_, project, event, window, cx| { + move |workspace, project, event, window, cx| { if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) = event { dev_container_suggest::suggest_on_worktree_updated( + workspace, *worktree_id, updated_entries, project, @@ -1159,7 +1160,7 @@ impl PickerDelegate for RecentProjectsDelegate { .update(cx, |multi_workspace, window, cx| { multi_workspace.open_project( paths, - OpenMode::Replace, + OpenMode::Activate, window, cx, ) @@ -2002,7 +2003,7 @@ mod tests { use std::path::PathBuf; use editor::Editor; - use gpui::{TestAppContext, UpdateGlobal, WindowHandle}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; use serde_json::json; use settings::SettingsStore; @@ -2121,14 +2122,12 @@ mod tests { cx.dispatch_action(*multi_workspace, menu::Confirm); cx.run_until_parked(); - // prepare_to_close triggers a save prompt for the dirty buffer. - // Choose "Don't Save" (index 2) to discard and continue replacing. + // In multi-workspace mode, the dirty workspace is kept and a new one is + // opened alongside it — no save prompt needed. assert!( - cx.has_pending_prompt(), - "Should prompt to save dirty buffer before replacing workspace" + !cx.has_pending_prompt(), + "Should not prompt in multi-workspace mode — dirty workspace is kept" ); - cx.simulate_prompt_answer("Don't Save"); - cx.run_until_parked(); multi_workspace .update(cx, |multi_workspace, _, cx| { @@ -2142,8 +2141,8 @@ mod tests { ); assert!( - !multi_workspace.workspaces().contains(&dirty_workspace), - "The original dirty workspace should have been replaced" + multi_workspace.workspaces().contains(&dirty_workspace), + "The dirty workspace should still be present in multi-workspace mode" ); assert!( @@ -2243,6 +2242,71 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + ".devcontainer": { + "devcontainer.json": "{}" + }, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/project"))], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + + cx.run_until_parked(); + + cx.dispatch_action(*multi_workspace, OpenDevContainer); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert!( + multi_workspace + .active_modal::(cx) + .is_some(), + "Dev container modal should be open" + ); + }) + .unwrap(); + + // Click outside the modal (on the backdrop) to try to dismiss it + let mut vcx = VisualTestContext::from_window(*multi_workspace, cx); + vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default()); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert!( + multi_workspace + .active_modal::(cx) + .is_some(), + "Dev container modal should remain open during creation" + ); + }) + .unwrap(); + } + #[gpui::test] async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 404b0673ab8cf220385d1a0ce41a40156d469a01..7db09c88616879010352cbc2ac0fd0549982240b 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -54,7 +54,7 @@ use util::{ rel_path::RelPath, }; use workspace::{ - AppState, ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, + AppState, DismissDecision, ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, notifications::{DetachAndPromptErr, NotificationId}, open_remote_project_with_existing_connection, }; @@ -69,6 +69,7 @@ pub struct RemoteServerProjects { create_new_window: bool, dev_container_picker: Option>>, _subscription: Subscription, + allow_dismissal: bool, } struct CreateRemoteServer { @@ -920,6 +921,7 @@ impl RemoteServerProjects { create_new_window, dev_container_picker: None, _subscription, + allow_dismissal: true, } } @@ -1140,6 +1142,7 @@ impl RemoteServerProjects { } fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context) { + self.allow_dismissal = false; self.mode = Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new( DevContainerCreationProgress::Creating, cx, @@ -1309,6 +1312,7 @@ impl RemoteServerProjects { cx.emit(DismissEvent); } _ => { + self.allow_dismissal = true; self.mode = Mode::default_mode(&self.ssh_config_servers, cx); self.focus_handle(cx).focus(window, cx); cx.notify(); @@ -1875,6 +1879,7 @@ impl RemoteServerProjects { .ok(); entity .update_in(cx, |remote_server_projects, window, cx| { + remote_server_projects.allow_dismissal = true; remote_server_projects.mode = Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new( DevContainerCreationProgress::Error(format!("{e}")), @@ -1897,7 +1902,8 @@ impl RemoteServerProjects { .log_err(); entity - .update(cx, |_, cx| { + .update(cx, |this, cx| { + this.allow_dismissal = true; cx.emit(DismissEvent); }) .log_err(); @@ -2948,7 +2954,15 @@ fn get_text(element: &Entity, cx: &mut App) -> String { element.read(cx).text(cx).trim().to_string() } -impl ModalView for RemoteServerProjects {} +impl ModalView for RemoteServerProjects { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> DismissDecision { + DismissDecision::Dismiss(self.allow_dismissal) + } +} impl Focusable for RemoteServerProjects { fn focus_handle(&self, cx: &App) -> FocusHandle { diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index cda4eb8d5595c7572292bb1b0b4fbc10c8e30ae5..1fe0d2ae86aefdad45136c496f8049689d77e048 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -411,12 +411,16 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { .border_t_1() .border_color(cx.theme().colors().border_variant) .child({ - let open_action = workspace::Open::default(); + let open_action = workspace::Open { + create_new_window: false, + }; + Button::new("open_local_folder", "Add Local Project") .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) - .on_click(move |_, window, cx| { - window.dispatch_action(open_action.boxed_clone(), cx) - }) + .on_click(cx.listener(move |_, _, window, cx| { + window.dispatch_action(open_action.boxed_clone(), cx); + cx.emit(DismissEvent); + })) }) .child( Button::new("open_remote_folder", "Add Remote Project") @@ -427,7 +431,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { }, cx, )) - .on_click(|_, window, cx| { + .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action( OpenRemote { from_existing_connection: false, @@ -435,8 +439,9 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { } .boxed_clone(), cx, - ) - }), + ); + cx.emit(DismissEvent); + })), ) .into_any(), ) diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 9c08c4f5f4941a80afdd2d9cbb6f2c51ee8ec754..c53dd7c3fb68bc087216764536506f85117ffb36 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -246,7 +246,7 @@ impl WslOpenModal { false => !secondary, }; let open_mode = if replace_current_window { - workspace::OpenMode::Replace + workspace::OpenMode::Activate } else { workspace::OpenMode::NewWindow }; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index f31fc9ebec028b6a42a7cbc0d61cf9574a4a0f3c..c04d3630f92bcc27afb01a619176d3ae79d3fac7 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1273,7 +1273,7 @@ impl ConnectionPool { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum RemoteConnectionOptions { Ssh(SshConnectionOptions), Wsl(WslConnectionOptions), @@ -1285,7 +1285,10 @@ pub enum RemoteConnectionOptions { impl RemoteConnectionOptions { pub fn display_name(&self) -> String { match self { - RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), + RemoteConnectionOptions::Ssh(opts) => opts + .nickname + .clone() + .unwrap_or_else(|| opts.host.to_string()), RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), RemoteConnectionOptions::Docker(opts) => { if opts.use_podman { @@ -1300,6 +1303,32 @@ impl RemoteConnectionOptions { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssh_display_name_prefers_nickname() { + let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { + host: "1.2.3.4".into(), + nickname: Some("My Cool Project".to_string()), + ..Default::default() + }); + + assert_eq!(options.display_name(), "My Cool Project"); + } + + #[test] + fn test_ssh_display_name_falls_back_to_host() { + let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { + host: "1.2.3.4".into(), + ..Default::default() + }); + + assert_eq!(options.display_name(), "1.2.3.4"); + } +} + impl From for RemoteConnectionOptions { fn from(opts: SshConnectionOptions) -> Self { RemoteConnectionOptions::Ssh(opts) diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index eddfa1216927dffa88f63c00c2e373233b426e83..6322cd9193d383cfcd3e9ff5cb93670bcd136023 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -30,7 +30,18 @@ use crate::{ transport::parse_platform, }; -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive( + Debug, + Default, + Clone, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] pub struct DockerConnectionOptions { pub name: String, pub container_id: String, diff --git a/crates/remote/src/transport/mock.rs b/crates/remote/src/transport/mock.rs index 06e13196583fef9743e3f337bfe9cd9acf0efbca..f567d24eb122f72b4dbb79cdeb2c98c744f02da4 100644 --- a/crates/remote/src/transport/mock.rs +++ b/crates/remote/src/transport/mock.rs @@ -56,7 +56,7 @@ use std::{ use util::paths::{PathStyle, RemotePathBuf}; /// Unique identifier for a mock connection. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct MockConnectionOptions { pub id: u64, } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 42cfc8f86dc34712e6b2cd0e4b5d8f379e443834..1884ea43b6492efba91623eb1ab4c5a1ed4d3de1 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -45,7 +45,7 @@ pub(crate) struct SshRemoteConnection { _temp_dir: TempDir, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum SshConnectionHost { IpAddr(IpAddr), Hostname(String), @@ -102,7 +102,7 @@ fn bracket_ipv6(host: &str) -> String { } } -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct SshConnectionOptions { pub host: SshConnectionHost, pub username: Option, diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index 5a37e1c65bfe11221b60499779c57f0ce7dca364..1bbbaca2235c0bcf14c414a9419ab9dd92b4e814 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -28,7 +28,9 @@ use util::{ shell_builder::ShellBuilder, }; -#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)] +#[derive( + Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, schemars::JsonSchema, +)] pub struct WslConnectionOptions { pub distro_name: String, pub user: Option, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 86b7f93eb2c737cac55dbf2882f91ec277e4e174..f0f23577d31075ab815d6dba1cdbdccd275c184a 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -11,6 +11,7 @@ use languages::rust_lang; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs}; +use git::repository::Worktree as GitWorktree; use gpui::{AppContext as _, Entity, SharedString, TestAppContext}; use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ @@ -1539,6 +1540,87 @@ async fn test_copy_file_into_remote_project( ); } +#[gpui::test] +async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/code", + json!({ + "main_repo": { + ".git": {}, + "file.txt": "content", + }, + "no_git": { + "file.txt": "content", + }, + }), + ) + .await; + + // Create a linked worktree that points back to main_repo's .git. + fs.add_linked_worktree_for_repo( + Path::new("/code/main_repo/.git"), + false, + GitWorktree { + path: PathBuf::from("/code/linked_worktree"), + ref_name: Some("refs/heads/feature-branch".into()), + sha: "abc123".into(), + is_main: false, + }, + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + + // Main repo: root_repo_common_dir should be the .git directory itself. + let (worktree_main, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree("/code/main_repo", true, cx) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let common_dir = worktree_main.read_with(cx, |worktree, _| { + worktree.snapshot().root_repo_common_dir().cloned() + }); + assert_eq!( + common_dir.as_deref(), + Some(Path::new("/code/main_repo/.git")), + ); + + // Linked worktree: root_repo_common_dir should point to the main repo's .git. + let (worktree_linked, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree("/code/linked_worktree", true, cx) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let common_dir = worktree_linked.read_with(cx, |worktree, _| { + worktree.snapshot().root_repo_common_dir().cloned() + }); + assert_eq!( + common_dir.as_deref(), + Some(Path::new("/code/main_repo/.git")), + ); + + // No git repo: root_repo_common_dir should be None. + let (worktree_no_git, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree("/code/no_git", true, cx) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let common_dir = worktree_no_git.read_with(cx, |worktree, _| { + worktree.snapshot().root_repo_common_dir().cloned() + }); + assert_eq!(common_dir, None); +} + #[gpui::test] async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let text_2 = " @@ -1917,6 +1999,153 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA assert_eq!(server_branch.name(), "totally-new-branch"); } +#[gpui::test] +async fn test_remote_git_checkpoints(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "file.txt": "original content", + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + + let (_worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap()); + + // 1. Create a checkpoint of the original state + let checkpoint_1 = repository + .update(cx, |repository, _| repository.checkpoint()) + .await + .unwrap() + .unwrap(); + + // 2. Modify a file on the server-side fs + fs.write( + Path::new(path!("/code/project1/file.txt")), + b"modified content", + ) + .await + .unwrap(); + + // 3. Create a second checkpoint with the modified state + let checkpoint_2 = repository + .update(cx, |repository, _| repository.checkpoint()) + .await + .unwrap() + .unwrap(); + + // 4. compare_checkpoints: same checkpoint with itself => equal + let equal = repository + .update(cx, |repository, _| { + repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_1.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!(equal, "a checkpoint compared with itself should be equal"); + + // 5. compare_checkpoints: different states => not equal + let equal = repository + .update(cx, |repository, _| { + repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!( + !equal, + "checkpoints of different states should not be equal" + ); + + // 6. diff_checkpoints: same checkpoint => empty diff + let diff = repository + .update(cx, |repository, _| { + repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_1.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!( + diff.is_empty(), + "diff of identical checkpoints should be empty" + ); + + // 7. diff_checkpoints: different checkpoints => non-empty diff mentioning the changed file + let diff = repository + .update(cx, |repository, _| { + repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!( + !diff.is_empty(), + "diff of different checkpoints should be non-empty" + ); + assert!( + diff.contains("file.txt"), + "diff should mention the changed file" + ); + assert!( + diff.contains("original content"), + "diff should contain removed content" + ); + assert!( + diff.contains("modified content"), + "diff should contain added content" + ); + + // 8. restore_checkpoint: restore to original state + repository + .update(cx, |repository, _| { + repository.restore_checkpoint(checkpoint_1.clone()) + }) + .await + .unwrap() + .unwrap(); + cx.run_until_parked(); + + // 9. Create a checkpoint after restore + let checkpoint_3 = repository + .update(cx, |repository, _| repository.checkpoint()) + .await + .unwrap() + .unwrap(); + + // 10. compare_checkpoints: restored state matches original + let equal = repository + .update(cx, |repository, _| { + repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_3.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!(equal, "restored state should match original checkpoint"); + + // 11. diff_checkpoints: restored state vs original => empty diff + let diff = repository + .update(cx, |repository, _| { + repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_3.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!(diff.is_empty(), "diff after restore should be empty"); +} + #[gpui::test] async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); diff --git a/crates/repl/src/kernels/mod.rs b/crates/repl/src/kernels/mod.rs index 6745bcd9b6a08cb34b3a0fc3b8219918cb4f0dca..9f08876cd39f4b7441d8c97bd1d5344b944b09ff 100644 --- a/crates/repl/src/kernels/mod.rs +++ b/crates/repl/src/kernels/mod.rs @@ -177,6 +177,13 @@ impl PythonEnvKernelSpecification { kernelspec: self.kernelspec.clone(), } } + + pub fn is_uv(&self) -> bool { + matches!( + self.environment_kind.as_deref(), + Some("uv" | "uv (Workspace)") + ) + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index cf1493000edb5881bff412224f7e44dbfbf88b25..61bed513a16c3b9baf885714110c3de78a7094d5 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -87,6 +87,7 @@ pub fn install_ipykernel_and_assign( let python_path = env_spec.path.clone(); let env_name = env_spec.name.clone(); + let is_uv = env_spec.is_uv(); let env_spec = env_spec.clone(); struct IpykernelInstall; @@ -109,11 +110,25 @@ pub fn install_ipykernel_and_assign( let window_handle = window.window_handle(); let install_task = cx.background_spawn(async move { - let output = util::command::new_command(python_path.to_string_lossy().as_ref()) - .args(&["-m", "pip", "install", "ipykernel"]) - .output() - .await - .context("failed to run pip install ipykernel")?; + let output = if is_uv { + util::command::new_command("uv") + .args(&[ + "pip", + "install", + "ipykernel", + "--python", + &python_path.to_string_lossy(), + ]) + .output() + .await + .context("failed to run uv pip install ipykernel")? + } else { + util::command::new_command(python_path.to_string_lossy().as_ref()) + .args(&["-m", "pip", "install", "ipykernel"]) + .output() + .await + .context("failed to run pip install ipykernel")? + }; if output.status.success() { anyhow::Ok(()) @@ -146,6 +161,11 @@ pub fn install_ipykernel_and_assign( window_handle .update(cx, |_, window, cx| { + let store = ReplStore::global(cx); + store.update(cx, |store, cx| { + store.mark_ipykernel_installed(cx, &env_spec); + }); + let updated_spec = KernelSpecification::PythonEnv(PythonEnvKernelSpecification { has_ipykernel: true, diff --git a/crates/repl/src/repl_store.rs b/crates/repl/src/repl_store.rs index cf992a542830bd86c1a9ad8b1909501417f427fd..4c5827b7c0cf881725b2937cc0aef0b7e241f0f3 100644 --- a/crates/repl/src/repl_store.rs +++ b/crates/repl/src/repl_store.rs @@ -13,8 +13,8 @@ use settings::{Settings, SettingsStore}; use util::rel_path::RelPath; use crate::kernels::{ - Kernel, list_remote_kernelspecs, local_kernel_specifications, python_env_kernel_specifications, - wsl_kernel_specifications, + Kernel, PythonEnvKernelSpecification, list_remote_kernelspecs, local_kernel_specifications, + python_env_kernel_specifications, wsl_kernel_specifications, }; use crate::{JupyterSettings, KernelSpecification, Session}; @@ -136,6 +136,23 @@ impl ReplStore { cx.notify(); } + pub fn mark_ipykernel_installed( + &mut self, + cx: &mut Context, + spec: &PythonEnvKernelSpecification, + ) { + for specs in self.kernel_specifications_for_worktree.values_mut() { + for kernel_spec in specs.iter_mut() { + if let KernelSpecification::PythonEnv(env_spec) = kernel_spec { + if env_spec == spec { + env_spec.has_ipykernel = true; + } + } + } + } + cx.notify(); + } + pub fn refresh_python_kernelspecs( &mut self, worktree_id: WorktreeId, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 93fbab59a6f1b9da0cb9faf0657fc4a1c5f679bd..46177c5642a8d05daaf22e9fb24b205cd10ca42b 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -114,81 +114,23 @@ impl Render for BufferSearchBar { .map(|splittable_editor| { let editor_ref = splittable_editor.read(cx); let diff_view_style = editor_ref.diff_view_style(); - let is_split = editor_ref.is_split(); + + let is_split_set = diff_view_style == DiffViewStyle::Split; + let is_split_active = editor_ref.is_split(); let min_columns = EditorSettings::get_global(cx).minimum_split_diff_width as u32; - let mut split_button = IconButton::new("diff-split", IconName::DiffSplit) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::element(move |_, cx| { - let message = if min_columns == 0 { - SharedString::from("Split") - } else { - format!("Split when wider than {} columns", min_columns).into() - }; - - v_flex() - .child(message) - .child( - h_flex() - .gap_0p5() - .text_ui_sm(cx) - .text_color(Color::Muted.color(cx)) - .children(render_modifiers( - &gpui::Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(TextSize::Small.rems(cx).into()), - false, - )) - .child("click to change min width"), - ) - .into_any() - })) - .on_click({ - let splittable_editor = splittable_editor.downgrade(); - move |_, window, cx| { - if window.modifiers().secondary() { - window.dispatch_action( - OpenSettingsAt { - path: "minimum_split_diff_width".to_string(), - } - .boxed_clone(), - cx, - ); - } else { - update_settings_file( - ::global(cx), - cx, - |settings, _| { - settings.editor.diff_view_style = - Some(DiffViewStyle::Split); - }, - ); - if diff_view_style == DiffViewStyle::Unified { - splittable_editor - .update(cx, |editor, cx| { - editor.toggle_split(&ToggleSplitDiff, window, cx); - }) - .ok(); - } - } - } - }); - - if diff_view_style == DiffViewStyle::Split { - if !is_split { - split_button = split_button.icon_color(Color::Disabled) - } else { - split_button = split_button.toggle_state(true) - } - } + let split_icon = if is_split_set && !is_split_active { + IconName::DiffSplitAuto + } else { + IconName::DiffSplit + }; h_flex() .gap_1() .child( IconButton::new("diff-unified", IconName::DiffUnified) - .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) .toggle_state(diff_view_style == DiffViewStyle::Unified) .tooltip(Tooltip::text("Unified")) .on_click({ @@ -216,7 +158,71 @@ impl Render for BufferSearchBar { } }), ) - .child(split_button) + .child( + IconButton::new("diff-split", split_icon) + .toggle_state(diff_view_style == DiffViewStyle::Split) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_, cx| { + let message = if is_split_set && !is_split_active { + format!("Split when wider than {} columns", min_columns) + .into() + } else { + SharedString::from("Split") + }; + + v_flex() + .child(message) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .text_color(Color::Muted.color(cx)) + .children(render_modifiers( + &gpui::Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Small.rems(cx).into()), + false, + )) + .child("click to change min width"), + ) + .into_any() + })) + .on_click({ + let splittable_editor = splittable_editor.downgrade(); + move |_, window, cx| { + if window.modifiers().secondary() { + window.dispatch_action( + OpenSettingsAt { + path: "minimum_split_diff_width".to_string(), + } + .boxed_clone(), + cx, + ); + } else { + update_settings_file( + ::global(cx), + cx, + |settings, _| { + settings.editor.diff_view_style = + Some(DiffViewStyle::Split); + }, + ); + if diff_view_style == DiffViewStyle::Unified { + splittable_editor + .update(cx, |editor, cx| { + editor.toggle_split( + &ToggleSplitDiff, + window, + cx, + ); + }) + .ok(); + } + } + } + }), + ) }) } else { None @@ -240,7 +246,7 @@ impl Render for BufferSearchBar { let collapse_expand_icon_button = |id| { IconButton::new(id, icon) - .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action_in( tooltip_label, @@ -285,6 +291,7 @@ impl Render for BufferSearchBar { regex, replacement, selection, + select_all, find_in_results, } = self.supported_options(cx); @@ -455,14 +462,16 @@ impl Render for BufferSearchBar { )) }); - el.child(render_action_button( - "buffer-search-nav-button", - IconName::SelectAll, - Default::default(), - "Select All Matches", - &SelectAllMatches, - query_focus, - )) + el.when(select_all, |el| { + el.child(render_action_button( + "buffer-search-nav-button", + IconName::SelectAll, + Default::default(), + "Select All Matches", + &SelectAllMatches, + query_focus.clone(), + )) + }) .child(matches_column) }) .when(find_in_results, |el| { @@ -3400,17 +3409,15 @@ mod tests { assert_eq!(initial_location, ToolbarItemLocation::Secondary); - let mut events = cx.events(&search_bar); + let mut events = cx.events::(&search_bar); search_bar.update_in(cx, |search_bar, window, cx| { search_bar.dismiss(&Dismiss, window, cx); }); assert_eq!( - events.try_next().unwrap(), - Some(ToolbarItemEvent::ChangeLocation( - ToolbarItemLocation::Hidden - )) + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden)) ); search_bar.update_in(cx, |search_bar, window, cx| { @@ -3418,10 +3425,8 @@ mod tests { }); assert_eq!( - events.try_next().unwrap(), - Some(ToolbarItemEvent::ChangeLocation( - ToolbarItemLocation::Secondary - )) + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary)) ); } @@ -3436,17 +3441,15 @@ mod tests { assert_eq!(initial_location, ToolbarItemLocation::PrimaryLeft); - let mut events = cx.events(&search_bar); + let mut events = cx.events::(&search_bar); search_bar.update_in(cx, |search_bar, window, cx| { search_bar.dismiss(&Dismiss, window, cx); }); assert_eq!( - events.try_next().unwrap(), - Some(ToolbarItemEvent::ChangeLocation( - ToolbarItemLocation::PrimaryLeft - )) + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft)) ); search_bar.update_in(cx, |search_bar, window, cx| { @@ -3454,10 +3457,8 @@ mod tests { }); assert_eq!( - events.try_next().unwrap(), - Some(ToolbarItemEvent::ChangeLocation( - ToolbarItemLocation::PrimaryLeft - )) + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft)) ); } @@ -3476,17 +3477,15 @@ mod tests { assert_eq!(initial_location, ToolbarItemLocation::Hidden); - let mut events = cx.events(&search_bar); + let mut events = cx.events::(&search_bar); search_bar.update_in(cx, |search_bar, window, cx| { search_bar.dismiss(&Dismiss, window, cx); }); assert_eq!( - events.try_next().unwrap(), - Some(ToolbarItemEvent::ChangeLocation( - ToolbarItemLocation::Hidden - )) + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden)) ); search_bar.update_in(cx, |search_bar, window, cx| { @@ -3494,10 +3493,8 @@ mod tests { }); assert_eq!( - events.try_next().unwrap(), - Some(ToolbarItemEvent::ChangeLocation( - ToolbarItemLocation::Secondary - )) + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary)) ); } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index c40b38c460a17f30b1fce26c50b40a893f7724a8..1211cbd8a4519ea295773eb0d979b48258908311 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -999,6 +999,7 @@ impl VsCodeSettings { } }), zoomed_padding: None, + focus_follows_mouse: None, } } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 7ec6a6b5bbdee57cbe75c13d1abe5277ac4f1825..5b1b3c014f8c538cb0dff506e05d84a80dc863d1 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -209,6 +209,11 @@ pub struct AgentSettingsContent { /// /// Default: false pub show_turn_stats: Option, + /// Whether to show the merge conflict indicator in the status bar + /// that offers to resolve conflicts using the agent. + /// + /// Default: true + pub show_merge_conflict_indicator: Option, /// Per-tool permission rules for granular control over which tool actions /// require confirmation. /// diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index fef92dc8f43d52c160c1e8c8a2fb7aeb0533e2c0..4b72c2ad3f47d834dfa38555d80a8646e3940f51 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -278,6 +278,7 @@ pub struct OpenAiCompatibleAvailableModel { pub max_tokens: u64, pub max_output_tokens: Option, pub max_completion_tokens: Option, + pub reasoning_effort: Option, #[serde(default)] pub capabilities: OpenAiCompatibleModelCapabilities, } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index ef00a44790fd10b8c56278362a2f552a40f52cbb..0bae7c260f6607f2015f750e5bb9dec7cc26342d 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -122,6 +122,9 @@ pub struct WorkspaceSettingsContent { /// What draws window decorations/titlebar, the client application (Zed) or display server /// Default: client pub window_decorations: Option, + /// Whether the focused panel follows the mouse location + /// Default: false + pub focus_follows_mouse: Option, } #[with_fallible_options] @@ -928,3 +931,10 @@ impl DocumentSymbols { self == &Self::On } } + +#[with_fallible_options] +#[derive(Copy, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] +pub struct FocusFollowsMouse { + pub enabled: Option, + pub debounce_ms: Option, +} diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 9d79481596f4b4259760ff6c2f19f8f5cf709d1e..0228f6886fc741505ffbe02fe82242d5f3e1dfd4 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -59,6 +59,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +zed_credentials_provider.workspace = true [dev-dependencies] fs = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 8496620f9b4db94f93b2ea65952423b73512e724..bacfd227d83933d3ebd9b2d8836bbe19958acf2b 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4159,7 +4159,7 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn layout_section() -> [SettingsPageItem; 4] { + fn layout_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Layout"), SettingsPageItem::SettingItem(SettingItem { @@ -4223,6 +4223,52 @@ fn window_and_layout_page() -> SettingsPage { }), metadata: None, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Focus Follows Mouse", + description: "Whether to change focus to a pane when the mouse hovers over it.", + field: Box::new(SettingField { + json_path: Some("focus_follows_mouse.enabled"), + pick: |settings_content| { + settings_content + .workspace + .focus_follows_mouse + .as_ref() + .and_then(|s| s.enabled.as_ref()) + }, + write: |settings_content, value| { + settings_content + .workspace + .focus_follows_mouse + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Focus Follows Mouse Debounce ms", + description: "Amount of time to wait before changing focus.", + field: Box::new(SettingField { + json_path: Some("focus_follows_mouse.debounce_ms"), + pick: |settings_content| { + settings_content + .workspace + .focus_follows_mouse + .as_ref() + .and_then(|s| s.debounce_ms.as_ref()) + }, + write: |settings_content, value| { + settings_content + .workspace + .focus_follows_mouse + .get_or_insert_default() + .debounce_ms = value; + }, + }), + metadata: None, + files: USER, + }), ] } @@ -4990,7 +5036,7 @@ fn panels_page() -> SettingsPage { ] } - fn terminal_panel_section() -> [SettingsPageItem; 3] { + fn terminal_panel_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("Terminal Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5006,6 +5052,19 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Terminal Panel Flexible Sizing", + description: "Whether the terminal panel should use flexible (proportional) sizing when docked to the left or right.", + field: Box::new(SettingField { + json_path: Some("terminal.flexible"), + pick: |settings_content| settings_content.terminal.as_ref()?.flexible.as_ref(), + write: |settings_content, value| { + settings_content.terminal.get_or_insert_default().flexible = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Count Badge", description: "Show a badge on the terminal panel icon with the count of open terminals.", @@ -5666,7 +5725,7 @@ fn panels_page() -> SettingsPage { ] } - fn agent_panel_section() -> [SettingsPageItem; 5] { + fn agent_panel_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Agent Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5695,6 +5754,19 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Agent Panel Flexible Sizing", + description: "Whether the agent panel should use flexible (proportional) sizing when docked to the left or right.", + field: Box::new(SettingField { + json_path: Some("agent.flexible"), + pick: |settings_content| settings_content.agent.as_ref()?.flexible.as_ref(), + write: |settings_content, value| { + settings_content.agent.get_or_insert_default().flexible = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Agent Panel Default Width", description: "Default width when the agent panel is docked to the left or right.", @@ -7444,6 +7516,24 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Merge Conflict Indicator", + description: "Whether to show the merge conflict indicator in the status bar that offers to resolve conflicts using the agent.", + field: Box::new(SettingField { + json_path: Some("agent.show_merge_conflict_indicator"), + pick: |settings_content| { + settings_content.agent.as_ref()?.show_merge_conflict_indicator.as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .show_merge_conflict_indicator = value; + }, + }), + metadata: None, + files: USER, + }), ]); items.into_boxed_slice() diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 193be67aad4760763637f116fad23066438b5b61..a2a457d33eb0788ff0bed981ce5666423890f05a 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -185,9 +185,15 @@ fn render_api_key_provider( cx: &mut Context, ) -> impl IntoElement { let weak_page = cx.weak_entity(); + let credentials_provider = zed_credentials_provider::global(cx); _ = window.use_keyed_state(current_url(cx), cx, |_, cx| { let task = api_key_state.update(cx, |key_state, cx| { - key_state.load_if_needed(current_url(cx), |state| state, cx) + key_state.load_if_needed( + current_url(cx), + |state| state, + credentials_provider.clone(), + cx, + ) }); cx.spawn(async move |_, cx| { task.await.ok(); @@ -208,10 +214,17 @@ fn render_api_key_provider( }); let write_key = move |api_key: Option, cx: &mut App| { + let credentials_provider = zed_credentials_provider::global(cx); api_key_state .update(cx, |key_state, cx| { let url = current_url(cx); - key_state.store(url, api_key, |key_state| key_state, cx) + key_state.store( + url, + api_key, + |key_state| key_state, + credentials_provider, + cx, + ) }) .detach_and_log_err(cx); }; diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 70aaaa15412793aae54c7c29fe8a2613854c8adb..634db0e247fdc370c479df0ed4f6d1f84a5284f6 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -500,18 +500,18 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::>(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::>(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -4051,41 +4051,6 @@ fn render_toggle_button + From + Copy>( .into_any_element() } -fn render_number_field( - field: SettingField, - file: SettingsUiFile, - _metadata: Option<&SettingsFieldMetadata>, - window: &mut Window, - cx: &mut App, -) -> AnyElement { - let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick); - let value = value.copied().unwrap_or_else(T::min_value); - - let id = field - .json_path - .map(|p| format!("numeric_stepper_{}", p)) - .unwrap_or_else(|| "numeric_stepper".to_string()); - - NumberField::new(id, value, window, cx) - .tab_index(0_isize) - .on_change({ - move |value, window, cx| { - let value = *value; - update_settings_file( - file.clone(), - field.json_path, - window, - cx, - move |settings, _cx| { - (field.write)(settings, Some(value)); - }, - ) - .log_err(); // todo(settings_ui) don't log err - } - }) - .into_any_element() -} - fn render_editable_number_field( field: SettingField, file: SettingsUiFile, diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 04ed8808a14d4c6853b08669523d55a2ebba4482..d76fd139557dd10438d7cf98f9168d87dcae9804 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -23,7 +23,6 @@ agent_settings.workspace = true agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true chrono.workspace = true -collections.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs deleted file mode 100644 index 9d06c7d31f1e1b34676db84a4f8e50131897f94d..0000000000000000000000000000000000000000 --- a/crates/sidebar/src/project_group_builder.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! The sidebar groups threads by a canonical path list. -//! -//! Threads have a path list associated with them, but this is the absolute path -//! of whatever worktrees they were associated with. In the sidebar, we want to -//! group all threads by their main worktree, and then we add a worktree chip to -//! the sidebar entry when that thread is in another worktree. -//! -//! This module is provides the functions and structures necessary to do this -//! lookup and mapping. - -use collections::{HashMap, HashSet, vecmap::VecMap}; -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; - -use gpui::{App, Entity}; -use ui::SharedString; -use workspace::{MultiWorkspace, PathList, Workspace}; - -/// Identifies a project group by a set of paths the workspaces in this group -/// have. -/// -/// Paths are mapped to their main worktree path first so we can group -/// workspaces by main repos. -#[derive(PartialEq, Eq, Hash, Clone)] -pub struct ProjectGroupName { - path_list: PathList, -} - -impl ProjectGroupName { - pub fn display_name(&self) -> SharedString { - let mut names = Vec::with_capacity(self.path_list.paths().len()); - for abs_path in self.path_list.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - if names.is_empty() { - // TODO: Can we do something better in this case? - "Empty Workspace".into() - } else { - names.join(", ").into() - } - } - - pub fn path_list(&self) -> &PathList { - &self.path_list - } -} - -#[derive(Default)] -pub struct ProjectGroup { - pub workspaces: Vec>, - /// Root paths of all open workspaces in this group. Used to skip - /// redundant thread-store queries for linked worktrees that already - /// have an open workspace. - covered_paths: HashSet>, -} - -impl ProjectGroup { - fn add_workspace(&mut self, workspace: &Entity, cx: &App) { - if !self.workspaces.contains(workspace) { - self.workspaces.push(workspace.clone()); - } - for path in workspace.read(cx).root_paths(cx) { - self.covered_paths.insert(path); - } - } - - pub fn first_workspace(&self) -> &Entity { - self.workspaces - .first() - .expect("groups always have at least one workspace") - } - - pub fn main_workspace(&self, cx: &App) -> &Entity { - self.workspaces - .iter() - .find(|ws| { - !crate::root_repository_snapshots(ws, cx) - .any(|snapshot| snapshot.is_linked_worktree()) - }) - .unwrap_or_else(|| self.first_workspace()) - } -} - -pub struct ProjectGroupBuilder { - /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path - directory_mappings: HashMap, - project_groups: VecMap, -} - -impl ProjectGroupBuilder { - fn new() -> Self { - Self { - directory_mappings: HashMap::default(), - project_groups: VecMap::new(), - } - } - - pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { - let mut builder = Self::new(); - // First pass: collect all directory mappings from every workspace - // so we know how to canonicalize any path (including linked - // worktree paths discovered by the main repo's workspace). - for workspace in mw.workspaces() { - builder.add_workspace_mappings(workspace.read(cx), cx); - } - - // Second pass: group each workspace using canonical paths derived - // from the full set of mappings. - for workspace in mw.workspaces() { - let group_name = builder.canonical_workspace_paths(workspace, cx); - builder - .project_group_entry(&group_name) - .add_workspace(workspace, cx); - } - builder - } - - fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { - self.project_groups.entry_ref(name).or_insert_default() - } - - fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { - let old = self - .directory_mappings - .insert(PathBuf::from(work_directory), PathBuf::from(original_repo)); - if let Some(old) = old { - debug_assert_eq!( - &old, original_repo, - "all worktrees should map to the same main worktree" - ); - } - } - - pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) { - for repo in workspace.project().read(cx).repositories(cx).values() { - let snapshot = repo.read(cx).snapshot(); - - self.add_mapping( - &snapshot.work_directory_abs_path, - &snapshot.original_repo_abs_path, - ); - - for worktree in snapshot.linked_worktrees.iter() { - self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path); - } - } - } - - /// Derives the canonical group name for a workspace by canonicalizing - /// each of its root paths using the builder's directory mappings. - fn canonical_workspace_paths( - &self, - workspace: &Entity, - cx: &App, - ) -> ProjectGroupName { - let root_paths = workspace.read(cx).root_paths(cx); - let paths: Vec<_> = root_paths - .iter() - .map(|p| self.canonicalize_path(p).to_path_buf()) - .collect(); - ProjectGroupName { - path_list: PathList::new(&paths), - } - } - - pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path { - self.directory_mappings - .get(path) - .map(AsRef::as_ref) - .unwrap_or(path) - } - - /// Whether the given group should load threads for a linked worktree - /// at `worktree_path`. Returns `false` if the worktree already has an - /// open workspace in the group (its threads are loaded via the - /// workspace loop) or if the worktree's canonical path list doesn't - /// match `group_path_list`. - pub fn group_owns_worktree( - &self, - group: &ProjectGroup, - group_path_list: &PathList, - worktree_path: &Path, - ) -> bool { - if group.covered_paths.contains(worktree_path) { - return false; - } - let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); - canonical == *group_path_list - } - - /// Canonicalizes every path in a [`PathList`] using the builder's - /// directory mappings. - fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { - let paths: Vec<_> = path_list - .paths() - .iter() - .map(|p| self.canonicalize_path(p).to_path_buf()) - .collect(); - PathList::new(&paths) - } - - pub fn groups(&self) -> impl Iterator { - self.project_groups.iter() - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use fs::FakeFs; - use gpui::TestAppContext; - use settings::SettingsStore; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - }); - } - - async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt/feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.add_linked_worktree_for_repo( - std::path::Path::new("/project/.git"), - false, - git::repository::Worktree { - path: std::path::PathBuf::from("/wt/feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "abc".into(), - is_main: false, - }, - ) - .await; - fs - } - - #[gpui::test] - async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) { - init_test(cx); - let fs = create_fs_with_main_and_worktree(cx).await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - workspace::MultiWorkspace::test_new(project.clone(), window, cx) - }); - - multi_workspace.read_with(cx, |mw, cx| { - let mut canonicalizer = ProjectGroupBuilder::new(); - for workspace in mw.workspaces() { - canonicalizer.add_workspace_mappings(workspace.read(cx), cx); - } - - // The main repo path should canonicalize to itself. - assert_eq!( - canonicalizer.canonicalize_path(Path::new("/project")), - Path::new("/project"), - ); - - // An unknown path returns None. - assert_eq!( - canonicalizer.canonicalize_path(Path::new("/something/else")), - Path::new("/something/else"), - ); - }); - } - - #[gpui::test] - async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) { - init_test(cx); - let fs = create_fs_with_main_and_worktree(cx).await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Open the worktree checkout as its own project. - let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await; - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - workspace::MultiWorkspace::test_new(project.clone(), window, cx) - }); - - multi_workspace.read_with(cx, |mw, cx| { - let mut canonicalizer = ProjectGroupBuilder::new(); - for workspace in mw.workspaces() { - canonicalizer.add_workspace_mappings(workspace.read(cx), cx); - } - - // The worktree checkout path should canonicalize to the main repo. - assert_eq!( - canonicalizer.canonicalize_path(Path::new("/wt/feature-a")), - Path::new("/project"), - ); - }); - } -} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 515da09c843f23947dfaca55f799134c5ce46d53..18b22b314604f155563ee56ea91cf103e07395fc 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -23,7 +23,9 @@ use gpui::{ use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; -use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name}; +use project::{ + AgentId, AgentRegistryStore, Event as ProjectEvent, ProjectGroupKey, linked_worktree_short_name, +}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use remote::RemoteConnectionOptions; use ui::utils::platform_title_bar_height; @@ -54,10 +56,6 @@ use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent}; -use crate::project_group_builder::ProjectGroupBuilder; - -mod project_group_builder; - #[cfg(test)] mod sidebar_tests; @@ -136,13 +134,7 @@ impl ActiveEntry { (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => { thread.metadata.session_id == *session_id } - ( - ActiveEntry::Draft(workspace), - ListEntry::NewThread { - workspace: entry_workspace, - .. - }, - ) => workspace == entry_workspace, + (ActiveEntry::Draft(_workspace), ListEntry::DraftThread { .. }) => true, _ => false, } } @@ -209,9 +201,8 @@ impl ThreadEntry { #[derive(Clone)] enum ListEntry { ProjectHeader { - path_list: PathList, + key: ProjectGroupKey, label: SharedString, - workspace: Entity, highlight_positions: Vec, has_running_threads: bool, waiting_thread_count: usize, @@ -219,30 +210,25 @@ enum ListEntry { }, Thread(ThreadEntry), ViewMore { - path_list: PathList, + key: ProjectGroupKey, is_fully_expanded: bool, }, + /// The user's active draft thread. Shows a prefix of the currently-typed + /// prompt, or "Untitled Thread" if the prompt is empty. + DraftThread { + worktrees: Vec, + }, + /// A convenience row for starting a new thread. Shown when a project group + /// has no threads, or when the active workspace contains linked worktrees + /// with no threads for that specific worktree set. NewThread { - path_list: PathList, - workspace: Entity, + key: project::ProjectGroupKey, worktrees: Vec, }, } #[cfg(test)] impl ListEntry { - fn workspace(&self) -> Option> { - match self { - ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), - ListEntry::Thread(thread_entry) => match &thread_entry.workspace { - ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), - ThreadEntryWorkspace::Closed(_) => None, - }, - ListEntry::ViewMore { .. } => None, - ListEntry::NewThread { workspace, .. } => Some(workspace.clone()), - } - } - fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id), @@ -321,27 +307,32 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { /// Derives worktree display info from a thread's stored path list. /// -/// For each path in the thread's `folder_paths` that canonicalizes to a -/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`] -/// with the short worktree name and full path. +/// For each path in the thread's `folder_paths` that is not one of the +/// group's main paths (i.e. it's a git linked worktree), produces a +/// [`WorktreeInfo`] with the short worktree name and full path. fn worktree_info_from_thread_paths( folder_paths: &PathList, - project_groups: &ProjectGroupBuilder, + group_key: &project::ProjectGroupKey, ) -> Vec { + let main_paths = group_key.path_list().paths(); folder_paths .paths() .iter() .filter_map(|path| { - let canonical = project_groups.canonicalize_path(path); - if canonical != path.as_path() { - Some(WorktreeInfo { - name: linked_worktree_short_name(canonical, path).unwrap_or_default(), - full_path: SharedString::from(path.display().to_string()), - highlight_positions: Vec::new(), - }) - } else { - None + if main_paths.iter().any(|mp| mp.as_path() == path.as_path()) { + return None; } + // Find the main path whose file name matches this linked + // worktree's file name, falling back to the first main path. + let main_path = main_paths + .iter() + .find(|mp| mp.file_name() == path.file_name()) + .or(main_paths.first())?; + Some(WorktreeInfo { + name: linked_worktree_short_name(main_path, path).unwrap_or_default(), + full_path: SharedString::from(path.display().to_string()), + highlight_positions: Vec::new(), + }) }) .collect() } @@ -677,10 +668,38 @@ impl Sidebar { result } + /// Finds an open workspace whose project group key matches the given path list. + fn workspace_for_group(&self, path_list: &PathList, cx: &App) -> Option> { + let mw = self.multi_workspace.upgrade()?; + let mw = mw.read(cx); + mw.workspaces() + .iter() + .find(|ws| ws.read(cx).project_group_key(cx).path_list() == path_list) + .cloned() + } + + /// Opens a new workspace for a group that has no open workspaces. + fn open_workspace_for_group( + &mut self, + path_list: &PathList, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace + .update(cx, |this, cx| { + this.find_or_create_local_workspace(path_list.clone(), window, cx) + }) + .detach_and_log_err(cx); + } + /// Rebuilds the sidebar contents from current workspace and thread state. /// - /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git - /// repository, then populates thread entries from the metadata store and + /// Iterates [`MultiWorkspace::project_group_keys`] to determine project + /// groups, then populates thread entries from the metadata store and /// merges live thread info from active agent panels. /// /// Aim for a single forward pass over workspaces and threads plus an @@ -764,11 +783,6 @@ impl Sidebar { let mut current_session_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); - // Use ProjectGroupBuilder to canonically group workspaces by their - // main git repository. This replaces the manual absorbed-workspace - // detection that was here before. - let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); - let has_open_projects = workspaces .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); @@ -785,38 +799,28 @@ impl Sidebar { (icon, icon_from_external_svg) }; - for (group_name, group) in project_groups.groups() { - let path_list = group_name.path_list().clone(); + for (group_key, group_workspaces) in mw.project_groups(cx) { + let path_list = group_key.path_list().clone(); if path_list.paths().is_empty() { continue; } - let label = group_name.display_name(); + let label = group_key.display_name(); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); let is_active = active_workspace .as_ref() - .is_some_and(|active| group.workspaces.contains(active)); - - // Pick a representative workspace for the group: prefer the active - // workspace if it belongs to this group, otherwise use the main - // repo workspace (not a linked worktree). - let representative_workspace = active_workspace - .as_ref() - .filter(|_| is_active) - .unwrap_or_else(|| group.main_workspace(cx)); + .is_some_and(|active| group_workspaces.contains(active)); // Collect live thread infos from all workspaces in this group. - let live_infos: Vec<_> = group - .workspaces + let live_infos: Vec<_> = group_workspaces .iter() .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)) .collect(); let mut threads: Vec = Vec::new(); - let mut threadless_workspaces: Vec<(Entity, Vec)> = Vec::new(); let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; @@ -824,61 +828,88 @@ impl Sidebar { let mut seen_session_ids: HashSet = HashSet::new(); let thread_store = ThreadMetadataStore::global(cx); - // Load threads from each workspace in the group. - for workspace in &group.workspaces { - let ws_path_list = workspace_path_list(workspace, cx); - let mut workspace_rows = thread_store - .read(cx) - .entries_for_path(&ws_path_list) - .cloned() - .peekable(); - if workspace_rows.peek().is_none() { - let worktrees = - worktree_info_from_thread_paths(&ws_path_list, &project_groups); - threadless_workspaces.push((workspace.clone(), worktrees)); + // Build a lookup from workspace root paths to their workspace + // entity, used to assign ThreadEntryWorkspace::Open for threads + // whose folder_paths match an open workspace. + let workspace_by_path_list: HashMap> = + group_workspaces + .iter() + .map(|ws| (workspace_path_list(ws, cx), ws)) + .collect(); + + // Resolve a ThreadEntryWorkspace for a thread row. If any open + // workspace's root paths match the thread's folder_paths, use + // Open; otherwise use Closed. + let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace { + workspace_by_path_list + .get(&row.folder_paths) + .map(|ws| ThreadEntryWorkspace::Open((*ws).clone())) + .unwrap_or_else(|| ThreadEntryWorkspace::Closed(row.folder_paths.clone())) + }; + + // Build a ThreadEntry from a metadata row. + let make_thread_entry = |row: ThreadMetadata, + workspace: ThreadEntryWorkspace| + -> ThreadEntry { + let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); + let worktrees = worktree_info_from_thread_paths(&row.folder_paths, &group_key); + ThreadEntry { + metadata: row, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace, + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees, + diff_stats: DiffStats::default(), } - for row in workspace_rows { - if !seen_session_ids.insert(row.session_id.clone()) { - continue; - } - let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); - let worktrees = - worktree_info_from_thread_paths(&row.folder_paths, &project_groups); - threads.push(ThreadEntry { - metadata: row, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees, - diff_stats: DiffStats::default(), - }); + }; + + // === Main code path: one query per group via main_worktree_paths === + // The main_worktree_paths column is set on all new threads and + // points to the group's canonical paths regardless of which + // linked worktree the thread was opened in. + for row in thread_store + .read(cx) + .entries_for_main_worktree_path(&path_list) + .cloned() + { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; } + let workspace = resolve_workspace(&row); + threads.push(make_thread_entry(row, workspace)); } - // Load threads from linked git worktrees whose - // canonical paths belong to this group. - let linked_worktree_queries = group - .workspaces - .iter() - .flat_map(|ws| root_repository_snapshots(ws, cx)) - .filter(|snapshot| !snapshot.is_linked_worktree()) - .flat_map(|snapshot| { - snapshot - .linked_worktrees() - .iter() - .filter(|wt| { - project_groups.group_owns_worktree(group, &path_list, &wt.path) - }) - .map(|wt| PathList::new(std::slice::from_ref(&wt.path))) - .collect::>() - }); + // Legacy threads did not have `main_worktree_paths` populated, so they + // must be queried by their `folder_paths`. + + // Load any legacy threads for the main worktrees of this project group. + for row in thread_store.read(cx).entries_for_path(&path_list).cloned() { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; + } + let workspace = resolve_workspace(&row); + threads.push(make_thread_entry(row, workspace)); + } - for worktree_path_list in linked_worktree_queries { + // Load any legacy threads for any single linked wortree of this project group. + let mut linked_worktree_paths = HashSet::new(); + for workspace in &group_workspaces { + if workspace.read(cx).visible_worktrees(cx).count() != 1 { + continue; + } + for snapshot in root_repository_snapshots(workspace, cx) { + for linked_worktree in snapshot.linked_worktrees() { + linked_worktree_paths.insert(linked_worktree.path.clone()); + } + } + } + for path in linked_worktree_paths { + let worktree_path_list = PathList::new(std::slice::from_ref(&path)); for row in thread_store .read(cx) .entries_for_path(&worktree_path_list) @@ -887,67 +918,10 @@ impl Sidebar { if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); - let worktrees = - worktree_info_from_thread_paths(&row.folder_paths, &project_groups); - threads.push(ThreadEntry { - metadata: row, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees, - diff_stats: DiffStats::default(), - }); - } - } - - // Load threads from main worktrees when a workspace in this - // group is itself a linked worktree checkout. - let main_repo_queries: Vec = group - .workspaces - .iter() - .flat_map(|ws| root_repository_snapshots(ws, cx)) - .filter(|snapshot| snapshot.is_linked_worktree()) - .map(|snapshot| { - PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path)) - }) - .collect(); - - for main_repo_path_list in main_repo_queries { - let folder_path_matches = thread_store - .read(cx) - .entries_for_path(&main_repo_path_list) - .cloned(); - let main_worktree_path_matches = thread_store - .read(cx) - .entries_for_main_worktree_path(&main_repo_path_list) - .cloned(); - - for row in folder_path_matches.chain(main_worktree_path_matches) { - if !seen_session_ids.insert(row.session_id.clone()) { - continue; - } - let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); - let worktrees = - worktree_info_from_thread_paths(&row.folder_paths, &project_groups); - threads.push(ThreadEntry { - metadata: row, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Closed(main_repo_path_list.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees, - diff_stats: DiffStats::default(), - }); + threads.push(make_thread_entry( + row, + ThreadEntryWorkspace::Closed(worktree_path_list.clone()), + )); } } @@ -974,21 +948,21 @@ impl Sidebar { let session_id = &thread.metadata.session_id; - let is_thread_workspace_active = match &thread.workspace { - ThreadEntryWorkspace::Open(thread_workspace) => active_workspace - .as_ref() - .is_some_and(|active| active == thread_workspace), - ThreadEntryWorkspace::Closed(_) => false, - }; + let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| { + entry.is_active_thread(session_id) + && active_workspace + .as_ref() + .is_some_and(|active| active == entry.workspace()) + }); if thread.status == AgentThreadStatus::Completed - && !is_thread_workspace_active + && !is_active_thread && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { notified_threads.insert(session_id.clone()); } - if is_thread_workspace_active && !thread.is_background { + if is_active_thread && !thread.is_background { notified_threads.remove(session_id); } } @@ -1051,9 +1025,8 @@ impl Sidebar { project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { - path_list: path_list.clone(), + key: group_key.clone(), label, - workspace: representative_workspace.clone(), highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, @@ -1065,15 +1038,13 @@ impl Sidebar { entries.push(thread.into()); } } else { - let is_draft_for_workspace = is_active - && matches!(&self.active_entry, Some(ActiveEntry::Draft(_))) - && self.active_entry_workspace() == Some(representative_workspace); + let is_draft_for_group = is_active + && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws)); project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { - path_list: path_list.clone(), + key: group_key.clone(), label, - workspace: representative_workspace.clone(), highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, @@ -1084,25 +1055,61 @@ impl Sidebar { continue; } - // Emit "New Thread" entries for threadless workspaces - // and active drafts, right after the header. - for (workspace, worktrees) in &threadless_workspaces { - entries.push(ListEntry::NewThread { - path_list: path_list.clone(), - workspace: workspace.clone(), - worktrees: worktrees.clone(), - }); + // Emit a DraftThread entry when the active draft belongs to this group. + if is_draft_for_group { + if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry { + let ws_path_list = workspace_path_list(draft_ws, cx); + let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key); + entries.push(ListEntry::DraftThread { worktrees }); + } } - if is_draft_for_workspace - && !threadless_workspaces - .iter() - .any(|(ws, _)| ws == representative_workspace) + + // Emit a NewThread entry when: + // 1. The group has zero threads (convenient affordance). + // 2. The active workspace has linked worktrees but no threads + // for the active workspace's specific set of worktrees. + let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty(); + let active_ws_has_threadless_linked_worktrees = is_active + && !is_draft_for_group + && active_workspace.as_ref().is_some_and(|active_ws| { + let ws_path_list = workspace_path_list(active_ws, cx); + let has_linked_worktrees = + !worktree_info_from_thread_paths(&ws_path_list, &group_key).is_empty(); + if !has_linked_worktrees { + return false; + } + let thread_store = ThreadMetadataStore::global(cx); + let has_threads_for_ws = thread_store + .read(cx) + .entries_for_path(&ws_path_list) + .next() + .is_some() + || thread_store + .read(cx) + .entries_for_main_worktree_path(&ws_path_list) + .next() + .is_some(); + !has_threads_for_ws + }); + + if !is_draft_for_group + && (group_has_no_threads || active_ws_has_threadless_linked_worktrees) { - let ws_path_list = workspace_path_list(representative_workspace, cx); - let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups); + let worktrees = if active_ws_has_threadless_linked_worktrees { + active_workspace + .as_ref() + .map(|ws| { + worktree_info_from_thread_paths( + &workspace_path_list(ws, cx), + &group_key, + ) + }) + .unwrap_or_default() + } else { + Vec::new() + }; entries.push(ListEntry::NewThread { - path_list: path_list.clone(), - workspace: representative_workspace.clone(), + key: group_key.clone(), worktrees, }); } @@ -1148,7 +1155,7 @@ impl Sidebar { if total > DEFAULT_THREADS_SHOWN { entries.push(ListEntry::ViewMore { - path_list: path_list.clone(), + key: group_key.clone(), is_fully_expanded, }); } @@ -1236,9 +1243,8 @@ impl Sidebar { let rendered = match entry { ListEntry::ProjectHeader { - path_list, + key, label, - workspace, highlight_positions, has_running_threads, waiting_thread_count, @@ -1246,9 +1252,8 @@ impl Sidebar { } => self.render_project_header( ix, false, - path_list, + key, label, - workspace, highlight_positions, *has_running_threads, *waiting_thread_count, @@ -1258,29 +1263,22 @@ impl Sidebar { ), ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), ListEntry::ViewMore { - path_list, + key, is_fully_expanded, - } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx), - ListEntry::NewThread { - path_list, - workspace, - worktrees, - } => self.render_new_thread( - ix, - path_list, - workspace, - is_active, - worktrees, - is_selected, - cx, - ), + } => self.render_view_more(ix, key.path_list(), *is_fully_expanded, is_selected, cx), + ListEntry::DraftThread { worktrees, .. } => { + self.render_draft_thread(ix, is_active, worktrees, is_selected, cx) + } + ListEntry::NewThread { key, worktrees, .. } => { + self.render_new_thread(ix, key, worktrees, is_selected, cx) + } }; if is_group_header_after_first { v_flex() .w_full() .border_t_1() - .border_color(cx.theme().colors().border.opacity(0.5)) + .border_color(cx.theme().colors().border) .child(rendered) .into_any_element() } else { @@ -1291,13 +1289,9 @@ impl Sidebar { fn render_remote_project_icon( &self, ix: usize, - workspace: &Entity, - cx: &mut Context, + host: Option<&RemoteConnectionOptions>, ) -> Option { - let project = workspace.read(cx).project().read(cx); - let remote_connection_options = project.remote_connection_options(cx)?; - - let remote_icon_per_type = match remote_connection_options { + let remote_icon_per_type = match host? { RemoteConnectionOptions::Wsl(_) => IconName::Linux, RemoteConnectionOptions::Docker(_) => IconName::Box, _ => IconName::Server, @@ -1320,16 +1314,18 @@ impl Sidebar { &self, ix: usize, is_sticky: bool, - path_list: &PathList, + key: &ProjectGroupKey, label: &SharedString, - workspace: &Entity, highlight_positions: &[usize], has_running_threads: bool, waiting_thread_count: usize, is_active: bool, - is_selected: bool, + is_focused: bool, cx: &mut Context, ) -> AnyElement { + let path_list = key.path_list(); + let host = key.host(); + let id_prefix = if is_sticky { "sticky-" } else { "" }; let id = SharedString::from(format!("{id_prefix}project-header-{ix}")); let disclosure_id = SharedString::from(format!("disclosure-{ix}")); @@ -1342,16 +1338,15 @@ impl Sidebar { (IconName::ChevronDown, "Collapse Project") }; - let has_new_thread_entry = self - .contents - .entries - .get(ix + 1) - .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. })); + let has_new_thread_entry = self.contents.entries.get(ix + 1).is_some_and(|entry| { + matches!( + entry, + ListEntry::NewThread { .. } | ListEntry::DraftThread { .. } + ) + }); let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx); - let workspace_for_remove = workspace.clone(); - let workspace_for_menu = workspace.clone(); - let workspace_for_open = workspace.clone(); + let workspace = self.workspace_for_group(path_list, cx); let path_list_for_toggle = path_list.clone(); let path_list_for_collapse = path_list.clone(); @@ -1359,11 +1354,11 @@ impl Sidebar { let label = if highlight_positions.is_empty() { Label::new(label.clone()) - .color(Color::Muted) + .when(!is_active, |this| this.color(Color::Muted)) .into_any_element() } else { HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) - .color(Color::Muted) + .when(!is_active, |this| this.color(Color::Muted)) .into_any_element() }; @@ -1381,14 +1376,13 @@ impl Sidebar { .pr_1p5() .border_1() .map(|this| { - if is_selected { + if is_focused { this.border_color(color.border_focused) } else { this.border_color(gpui::transparent_black()) } }) .justify_between() - .hover(|s| s.bg(hover_color)) .child( h_flex() .when(!is_active, |this| this.cursor_pointer()) @@ -1409,7 +1403,7 @@ impl Sidebar { ) .child(label) .when_some( - self.render_remote_project_icon(ix, workspace, cx), + self.render_remote_project_icon(ix, host.as_ref()), |this, icon| this.child(icon), ) .when(is_collapsed, |this| { @@ -1442,10 +1436,7 @@ impl Sidebar { }) }), ) - .child({ - let workspace_for_new_thread = workspace.clone(); - let path_list_for_new_thread = path_list.clone(); - + .child( h_flex() .when(self.project_header_menu_ix != Some(ix), |this| { this.visible_on_hover(group_name) @@ -1453,13 +1444,7 @@ impl Sidebar { .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .child(self.render_project_header_menu( - ix, - id_prefix, - &workspace_for_menu, - &workspace_for_remove, - cx, - )) + .child(self.render_project_header_menu(ix, id_prefix, key, cx)) .when(view_more_expanded && !is_collapsed, |this| { this.child( IconButton::new( @@ -1469,7 +1454,6 @@ impl Sidebar { IconName::ListCollapse, ) .icon_size(IconSize::Small) - .icon_color(Color::Muted) .tooltip(Tooltip::text("Collapse Displayed Threads")) .on_click(cx.listener({ let path_list_for_collapse = path_list_for_collapse.clone(); @@ -1482,51 +1466,50 @@ impl Sidebar { })), ) }) - .when(show_new_thread_button, |this| { - this.child( - IconButton::new( - SharedString::from(format!( - "{id_prefix}project-header-new-thread-{ix}", + .when_some( + workspace.filter(|_| show_new_thread_button), + |this, workspace| { + let path_list = path_list.clone(); + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-new-thread-{ix}", + )), + IconName::Plus, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener( + move |this, _, window, cx| { + this.collapsed_groups.remove(&path_list); + this.selection = None; + this.create_new_thread(&workspace, window, cx); + }, )), - IconName::Plus, ) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("New Thread")) - .on_click(cx.listener({ - let workspace_for_new_thread = workspace_for_new_thread.clone(); - let path_list_for_new_thread = path_list_for_new_thread.clone(); - move |this, _, window, cx| { - // Uncollapse the group if collapsed so - // the new-thread entry becomes visible. - this.collapsed_groups.remove(&path_list_for_new_thread); - this.selection = None; - this.create_new_thread(&workspace_for_new_thread, window, cx); - } - })), - ) - }) - }) + }, + ), + ) .when(!is_active, |this| { - this.tooltip(Tooltip::text("Activate Workspace")) - .on_click(cx.listener({ - move |this, _, window, cx| { - this.active_entry = - Some(ActiveEntry::Draft(workspace_for_open.clone())); + let path_list = path_list.clone(); + this.cursor_pointer() + .hover(|s| s.bg(hover_color)) + .tooltip(Tooltip::text("Open Workspace")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(workspace) = this.workspace_for_group(&path_list, cx) { + this.active_entry = Some(ActiveEntry::Draft(workspace.clone())); if let Some(multi_workspace) = this.multi_workspace.upgrade() { multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate( - workspace_for_open.clone(), - window, - cx, - ); + multi_workspace.activate(workspace.clone(), window, cx); }); } - if AgentPanel::is_visible(&workspace_for_open, cx) { - workspace_for_open.update(cx, |workspace, cx| { + if AgentPanel::is_visible(&workspace, cx) { + workspace.update(cx, |workspace, cx| { workspace.focus_panel::(window, cx); }); } + } else { + this.open_workspace_for_group(&path_list, window, cx); } })) }) @@ -1537,14 +1520,12 @@ impl Sidebar { &self, ix: usize, id_prefix: &str, - workspace: &Entity, - workspace_for_remove: &Entity, + project_group_key: &ProjectGroupKey, cx: &mut Context, ) -> impl IntoElement { - let workspace_for_menu = workspace.clone(); - let workspace_for_remove = workspace_for_remove.clone(); let multi_workspace = self.multi_workspace.clone(); let this = cx.weak_entity(); + let project_group_key = project_group_key.clone(); PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}")) .on_open(Rc::new({ @@ -1558,116 +1539,102 @@ impl Sidebar { } })) .menu(move |window, cx| { - let workspace = workspace_for_menu.clone(); - let workspace_for_remove = workspace_for_remove.clone(); let multi_workspace = multi_workspace.clone(); + let project_group_key = project_group_key.clone(); let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| { - let worktrees: Vec<_> = workspace - .read(cx) - .visible_worktrees(cx) - .map(|worktree| { - let worktree_read = worktree.read(cx); - let id = worktree_read.id(); - let name: SharedString = - worktree_read.root_name().as_unix_str().to_string().into(); - (id, name) - }) - .collect(); - - let worktree_count = worktrees.len(); - let mut menu = menu .header("Project Folders") .end_slot_action(Box::new(menu::EndSlot)); - for (worktree_id, name) in &worktrees { - let worktree_id = *worktree_id; - let workspace_for_worktree = workspace.clone(); - let workspace_for_remove_worktree = workspace_for_remove.clone(); - let multi_workspace_for_worktree = multi_workspace.clone(); - - let remove_handler = move |window: &mut Window, cx: &mut App| { - if worktree_count <= 1 { - if let Some(mw) = multi_workspace_for_worktree.upgrade() { - let ws = workspace_for_remove_worktree.clone(); - mw.update(cx, |multi_workspace, cx| { - multi_workspace.remove(&ws, window, cx); - }); - } - } else { - workspace_for_worktree.update(cx, |workspace, cx| { - workspace.project().update(cx, |project, cx| { - project.remove_worktree(worktree_id, cx); - }); - }); - } + for path in project_group_key.path_list().paths() { + let Some(name) = path.file_name() else { + continue; }; - + let name: SharedString = name.to_string_lossy().into_owned().into(); + let path = path.clone(); + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); menu = menu.entry_with_end_slot_on_hover( name.clone(), None, |_, _| {}, IconName::Close, "Remove Folder".into(), - remove_handler, + move |_window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.remove_folder_from_project_group( + &project_group_key, + &path, + cx, + ); + }) + .ok(); + }, ); } - let workspace_for_add = workspace.clone(); - let multi_workspace_for_add = multi_workspace.clone(); let menu = menu.separator().entry( "Add Folder to Project", Some(Box::new(AddFolderToProject)), - move |window, cx| { - if let Some(mw) = multi_workspace_for_add.upgrade() { - mw.update(cx, |mw, cx| { - mw.activate(workspace_for_add.clone(), window, cx); - }); + { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); + move |window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.prompt_to_add_folders_to_project_group( + &project_group_key, + window, + cx, + ); + }) + .ok(); } - workspace_for_add.update(cx, |workspace, cx| { - workspace.add_folder_to_project(&AddFolderToProject, window, cx); - }); }, ); - let workspace_count = multi_workspace + let group_count = multi_workspace .upgrade() - .map_or(0, |mw| mw.read(cx).workspaces().len()); - let menu = if workspace_count > 1 { - let workspace_for_move = workspace.clone(); - let multi_workspace_for_move = multi_workspace.clone(); + .map_or(0, |mw| mw.read(cx).project_group_keys().count()); + let menu = if group_count > 1 { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); menu.entry( "Move to New Window", Some(Box::new( zed_actions::agents_sidebar::MoveWorkspaceToNewWindow, )), move |window, cx| { - if let Some(mw) = multi_workspace_for_move.upgrade() { - mw.update(cx, |multi_workspace, cx| { - multi_workspace.move_workspace_to_new_window( - &workspace_for_move, + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.move_project_group_to_new_window( + &project_group_key, window, cx, ); - }); - } + }) + .ok(); }, ) } else { menu }; - let workspace_for_remove = workspace_for_remove.clone(); - let multi_workspace_for_remove = multi_workspace.clone(); + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); menu.separator() .entry("Remove Project", None, move |window, cx| { - if let Some(mw) = multi_workspace_for_remove.upgrade() { - let ws = workspace_for_remove.clone(); - mw.update(cx, |multi_workspace, cx| { - multi_workspace.remove(&ws, window, cx); - }); - } + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.remove_project_group( + &project_group_key, + window, + cx, + ); + }) + .ok(); }) }); @@ -1690,8 +1657,7 @@ impl Sidebar { IconName::Ellipsis, ) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .icon_size(IconSize::Small), ) .anchor(gpui::Corner::TopRight) .offset(gpui::Point { @@ -1722,9 +1688,8 @@ impl Sidebar { } let ListEntry::ProjectHeader { - path_list, + key, label, - workspace, highlight_positions, has_running_threads, waiting_thread_count, @@ -1740,9 +1705,8 @@ impl Sidebar { let header_element = self.render_project_header( header_idx, true, - &path_list, + key, &label, - workspace, &highlight_positions, *has_running_threads, *waiting_thread_count, @@ -1963,8 +1927,8 @@ impl Sidebar { }; match entry { - ListEntry::ProjectHeader { path_list, .. } => { - let path_list = path_list.clone(); + ListEntry::ProjectHeader { key, .. } => { + let path_list = key.path_list().clone(); self.toggle_collapse(&path_list, window, cx); } ListEntry::Thread(thread) => { @@ -1985,11 +1949,11 @@ impl Sidebar { } } ListEntry::ViewMore { - path_list, + key, is_fully_expanded, .. } => { - let path_list = path_list.clone(); + let path_list = key.path_list().clone(); if *is_fully_expanded { self.expanded_groups.remove(&path_list); } else { @@ -1999,9 +1963,16 @@ impl Sidebar { self.serialize(cx); self.update_entries(cx); } - ListEntry::NewThread { workspace, .. } => { - let workspace = workspace.clone(); - self.create_new_thread(&workspace, window, cx); + ListEntry::DraftThread { .. } => { + // Already active — nothing to do. + } + ListEntry::NewThread { key, .. } => { + let path_list = key.path_list().clone(); + if let Some(workspace) = self.workspace_for_group(&path_list, cx) { + self.create_new_thread(&workspace, window, cx); + } else { + self.open_workspace_for_group(&path_list, window, cx); + } } } } @@ -2171,16 +2142,12 @@ impl Sidebar { return; }; - let paths: Vec = - path_list.paths().iter().map(|p| p.to_path_buf()).collect(); - - let open_task = multi_workspace.update(cx, |mw, cx| { - mw.open_project(paths, workspace::OpenMode::Activate, window, cx) + let open_task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_local_workspace(path_list, window, cx) }); cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; - this.update_in(cx, |this, window, cx| { this.activate_thread(metadata, &workspace, window, cx); })?; @@ -2253,9 +2220,9 @@ impl Sidebar { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { - Some(ListEntry::ProjectHeader { path_list, .. }) => { - if self.collapsed_groups.contains(path_list) { - let path_list = path_list.clone(); + Some(ListEntry::ProjectHeader { key, .. }) => { + if self.collapsed_groups.contains(key.path_list()) { + let path_list = key.path_list().clone(); self.collapsed_groups.remove(&path_list); self.update_entries(cx); } else if ix + 1 < self.contents.entries.len() { @@ -2277,23 +2244,23 @@ impl Sidebar { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { - Some(ListEntry::ProjectHeader { path_list, .. }) => { - if !self.collapsed_groups.contains(path_list) { - let path_list = path_list.clone(); - self.collapsed_groups.insert(path_list); + Some(ListEntry::ProjectHeader { key, .. }) => { + if !self.collapsed_groups.contains(key.path_list()) { + self.collapsed_groups.insert(key.path_list().clone()); self.update_entries(cx); } } Some( - ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, + ListEntry::Thread(_) + | ListEntry::ViewMore { .. } + | ListEntry::NewThread { .. } + | ListEntry::DraftThread { .. }, ) => { for i in (0..ix).rev() { - if let Some(ListEntry::ProjectHeader { path_list, .. }) = - self.contents.entries.get(i) + if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) { - let path_list = path_list.clone(); self.selection = Some(i); - self.collapsed_groups.insert(path_list); + self.collapsed_groups.insert(key.path_list().clone()); self.update_entries(cx); break; } @@ -2315,7 +2282,10 @@ impl Sidebar { let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), Some( - ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, + ListEntry::Thread(_) + | ListEntry::ViewMore { .. } + | ListEntry::NewThread { .. } + | ListEntry::DraftThread { .. }, ) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), @@ -2326,15 +2296,14 @@ impl Sidebar { }; if let Some(header_ix) = header_ix { - if let Some(ListEntry::ProjectHeader { path_list, .. }) = - self.contents.entries.get(header_ix) + if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix) { - let path_list = path_list.clone(); - if self.collapsed_groups.contains(&path_list) { - self.collapsed_groups.remove(&path_list); + let path_list = key.path_list(); + if self.collapsed_groups.contains(path_list) { + self.collapsed_groups.remove(path_list); } else { self.selection = Some(header_ix); - self.collapsed_groups.insert(path_list); + self.collapsed_groups.insert(path_list.clone()); } self.update_entries(cx); } @@ -2348,8 +2317,8 @@ impl Sidebar { cx: &mut Context, ) { for entry in &self.contents.entries { - if let ListEntry::ProjectHeader { path_list, .. } = entry { - self.collapsed_groups.insert(path_list.clone()); + if let ListEntry::ProjectHeader { key, .. } = entry { + self.collapsed_groups.insert(key.path_list().clone()); } } self.update_entries(cx); @@ -2404,17 +2373,18 @@ impl Sidebar { }); // Find the workspace that owns this thread's project group by - // walking backwards to the nearest ProjectHeader. We must use - // *this* workspace (not the active workspace) because the user - // might be archiving a thread in a non-active group. + // walking backwards to the nearest ProjectHeader and looking up + // an open workspace for that group's path_list. let group_workspace = current_pos.and_then(|pos| { - self.contents.entries[..pos] - .iter() - .rev() - .find_map(|e| match e { - ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), - _ => None, - }) + let path_list = + self.contents.entries[..pos] + .iter() + .rev() + .find_map(|e| match e { + ListEntry::ProjectHeader { key, .. } => Some(key.path_list()), + _ => None, + })?; + self.workspace_for_group(path_list, cx) }); let next_thread = current_pos.and_then(|pos| { @@ -2529,28 +2499,26 @@ impl Sidebar { .insert(session_id.clone(), Utc::now()); } - fn mru_threads_for_switcher(&self, _cx: &App) -> Vec { + fn mru_threads_for_switcher(&self, cx: &App) -> Vec { let mut current_header_label: Option = None; - let mut current_header_workspace: Option> = None; + let mut current_header_path_list: Option = None; let mut entries: Vec = self .contents .entries .iter() .filter_map(|entry| match entry { - ListEntry::ProjectHeader { - label, workspace, .. - } => { + ListEntry::ProjectHeader { label, key, .. } => { current_header_label = Some(label.clone()); - current_header_workspace = Some(workspace.clone()); + current_header_path_list = Some(key.path_list().clone()); None } ListEntry::Thread(thread) => { let workspace = match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => workspace.clone(), - ThreadEntryWorkspace::Closed(_) => { - current_header_workspace.as_ref()?.clone() - } - }; + ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), + ThreadEntryWorkspace::Closed(_) => current_header_path_list + .as_ref() + .and_then(|pl| self.workspace_for_group(pl, cx)), + }?; let notified = self .contents .is_thread_notified(&thread.metadata.session_id); @@ -2825,7 +2793,7 @@ impl Sidebar { let color = cx.theme().colors(); let sidebar_bg = color .title_bar_background - .blend(color.panel_background.opacity(0.32)); + .blend(color.panel_background.opacity(0.25)); let timestamp = format_history_entry_timestamp( self.thread_last_message_sent_or_queued @@ -3057,7 +3025,9 @@ impl Sidebar { .rev() .find(|&&header_ix| header_ix <= selected_ix) .and_then(|&header_ix| match &self.contents.entries[header_ix] { - ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), + ListEntry::ProjectHeader { key, .. } => { + self.workspace_for_group(key.path_list(), cx) + } _ => None, }) } else { @@ -3100,11 +3070,9 @@ impl Sidebar { }); } - fn render_new_thread( + fn render_draft_thread( &self, ix: usize, - _path_list: &PathList, - workspace: &Entity, is_active: bool, worktrees: &[WorktreeInfo], is_selected: bool, @@ -3112,12 +3080,48 @@ impl Sidebar { ) -> AnyElement { let label: SharedString = if is_active { self.active_draft_text(cx) - .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()) + .unwrap_or_else(|| "Untitled Thread".into()) } else { - DEFAULT_THREAD_TITLE.into() + "Untitled Thread".into() }; - let workspace = workspace.clone(); + let id = SharedString::from(format!("draft-thread-btn-{}", ix)); + + let thread_item = ThreadItem::new(id, label) + .icon(IconName::Plus) + .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8))) + .worktrees( + worktrees + .iter() + .map(|wt| ThreadItemWorktreeInfo { + name: wt.name.clone(), + full_path: wt.full_path.clone(), + highlight_positions: wt.highlight_positions.clone(), + }) + .collect(), + ) + .selected(true) + .focused(is_selected); + + div() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child(thread_item) + .into_any_element() + } + + fn render_new_thread( + &self, + ix: usize, + key: &ProjectGroupKey, + worktrees: &[WorktreeInfo], + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let label: SharedString = DEFAULT_THREAD_TITLE.into(); + let path_list = key.path_list().clone(); + let id = SharedString::from(format!("new-thread-btn-{}", ix)); let thread_item = ThreadItem::new(id, label) @@ -3133,25 +3137,18 @@ impl Sidebar { }) .collect(), ) - .selected(is_active) + .selected(false) .focused(is_selected) - .when(!is_active, |this| { - this.on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + if let Some(workspace) = this.workspace_for_group(&path_list, cx) { this.create_new_thread(&workspace, window, cx); - })) - }); + } else { + this.open_workspace_for_group(&path_list, window, cx); + } + })); - if is_active { - div() - .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .child(thread_item) - .into_any_element() - } else { - thread_item.into_any_element() - } + thread_item.into_any_element() } fn render_no_results(&self, cx: &mut Context) -> impl IntoElement { @@ -3682,7 +3679,7 @@ impl Render for Sidebar { let color = cx.theme().colors(); let bg = color .title_bar_background - .blend(color.panel_background.opacity(0.32)); + .blend(color.panel_background.opacity(0.25)); let no_open_projects = !self.contents.has_open_projects; let no_search_results = self.contents.entries.is_empty(); @@ -3831,6 +3828,15 @@ pub fn dump_workspace_info( .map(|mw| mw.read(cx).active_workspace_index()); writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok(); + + if let Some(mw) = &multi_workspace { + let keys: Vec<_> = mw.read(cx).project_group_keys().cloned().collect(); + writeln!(output, "Project group keys ({}):", keys.len()).ok(); + for key in keys { + writeln!(output, " - {key:?}").ok(); + } + } + if let Some(index) = active_index { writeln!(output, "Active workspace index: {index}").ok(); } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 1499fc48a9fd094b07d181701866ab941c5968f3..cf1ee8a0f524d9d94edf83c24ecea900f3261fb8 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -88,14 +88,18 @@ fn setup_sidebar( sidebar } -async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) { +async fn save_n_test_threads( + count: u32, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) { for i in 0..count { save_thread_metadata( acp::SessionId::new(Arc::from(format!("thread-{}", i))), format!("Thread {}", i + 1).into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), None, - path_list.clone(), + project, cx, ) } @@ -104,7 +108,7 @@ async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::Vi async fn save_test_thread_metadata( session_id: &acp::SessionId, - path_list: PathList, + project: &Entity, cx: &mut TestAppContext, ) { save_thread_metadata( @@ -112,7 +116,7 @@ async fn save_test_thread_metadata( "Test".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + project, cx, ) } @@ -120,7 +124,7 @@ async fn save_test_thread_metadata( async fn save_named_thread_metadata( session_id: &str, title: &str, - path_list: &PathList, + project: &Entity, cx: &mut gpui::VisualTestContext, ) { save_thread_metadata( @@ -128,7 +132,7 @@ async fn save_named_thread_metadata( SharedString::from(title.to_string()), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list.clone(), + project, cx, ); cx.run_until_parked(); @@ -139,21 +143,31 @@ fn save_thread_metadata( title: SharedString, updated_at: DateTime, created_at: Option>, - path_list: PathList, + project: &Entity, cx: &mut TestAppContext, ) { - let metadata = ThreadMetadata { - session_id, - agent_id: agent::ZED_AGENT_ID.clone(), - title, - updated_at, - created_at, - folder_paths: path_list, - main_worktree_paths: PathList::default(), - archived: false, - }; cx.update(|cx| { - ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx)) + let (folder_paths, main_worktree_paths) = { + let project_ref = project.read(cx); + let paths: Vec> = project_ref + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + let folder_paths = PathList::new(&paths); + let main_worktree_paths = project_ref.project_group_key(cx).path_list().clone(); + (folder_paths, main_worktree_paths) + }; + let metadata = ThreadMetadata { + session_id, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + updated_at, + created_at, + folder_paths, + main_worktree_paths, + archived: false, + }; + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx)); }); cx.run_until_parked(); } @@ -193,11 +207,11 @@ fn visible_entries_as_strings( match entry { ListEntry::ProjectHeader { label, - path_list, + key, highlight_positions: _, .. } => { - let icon = if sidebar.collapsed_groups.contains(path_list) { + let icon = if sidebar.collapsed_groups.contains(key.path_list()) { ">" } else { "v" @@ -248,6 +262,22 @@ fn visible_entries_as_strings( format!(" + View More{}", selected) } } + ListEntry::DraftThread { worktrees, .. } => { + let worktree = if worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; + format!(" [~ Draft{}]{}", worktree, selected) + } ListEntry::NewThread { worktrees, .. } => { let worktree = if worktrees.is_empty() { String::new() @@ -274,11 +304,14 @@ fn visible_entries_as_strings( async fn test_serialization_round_trip(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; + save_n_test_threads(3, &project, cx).await; + + let path_list = project.read_with(cx, |project, cx| { + project.project_group_key(cx).path_list().clone() + }); // Set a custom width, collapse the group, and expand "View More". sidebar.update_in(cx, |sidebar, window, cx| { @@ -437,17 +470,15 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("thread-1")), "Fix crash in project panel".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), None, - path_list.clone(), + &project, cx, ); @@ -456,7 +487,7 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { "Add inline diff view".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); cx.run_until_parked(); @@ -478,18 +509,16 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { async fn test_workspace_lifecycle(cx: &mut TestAppContext) { let project = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Single workspace with a thread - let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - save_thread_metadata( acp::SessionId::new(Arc::from("thread-a1")), "Thread A1".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); cx.run_until_parked(); @@ -530,11 +559,10 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) { async fn test_view_more_pagination(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(12, &path_list, cx).await; + save_n_test_threads(12, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -557,12 +585,15 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) { async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse - save_n_test_threads(17, &path_list, cx).await; + save_n_test_threads(17, &project, cx).await; + + let path_list = project.read_with(cx, |project, cx| { + project.project_group_key(cx).path_list().clone() + }); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -629,11 +660,14 @@ async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; + save_n_test_threads(1, &project, cx).await; + + let path_list = project.read_with(cx, |project, cx| { + project.project_group_key(cx).path_list().clone() + }); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -685,9 +719,8 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { s.contents.entries = vec![ // Expanded project header ListEntry::ProjectHeader { - path_list: expanded_path.clone(), + key: project::ProjectGroupKey::new(None, expanded_path.clone()), label: "expanded-project".into(), - workspace: workspace.clone(), highlight_positions: Vec::new(), has_running_threads: false, waiting_thread_count: 0, @@ -809,14 +842,13 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { }), // View More entry ListEntry::ViewMore { - path_list: expanded_path.clone(), + key: project::ProjectGroupKey::new(None, expanded_path.clone()), is_fully_expanded: false, }, // Collapsed project header ListEntry::ProjectHeader { - path_list: collapsed_path.clone(), + key: project::ProjectGroupKey::new(None, collapsed_path.clone()), label: "collapsed-project".into(), - workspace: workspace.clone(), highlight_positions: Vec::new(), has_running_threads: false, waiting_thread_count: 0, @@ -872,11 +904,10 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; + save_n_test_threads(3, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -932,11 +963,10 @@ async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; + save_n_test_threads(3, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -987,11 +1017,10 @@ async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; + save_n_test_threads(1, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -1029,11 +1058,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(8, &path_list, cx).await; + save_n_test_threads(8, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -1064,11 +1092,10 @@ async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; + save_n_test_threads(1, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -1109,11 +1136,10 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; + save_n_test_threads(1, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -1177,11 +1203,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; + save_n_test_threads(1, &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -1254,15 +1279,13 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Open thread A and keep it generating. let connection = StubAgentConnection::new(); open_thread_with_connection(&panel, connection.clone(), cx); send_message(&panel, cx); let session_id_a = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; + save_test_thread_metadata(&session_id_a, &project, cx).await; cx.update(|_, cx| { connection.send_update( @@ -1281,7 +1304,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; + save_test_thread_metadata(&session_id_b, &project, cx).await; cx.run_until_parked(); @@ -1300,15 +1323,13 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - // Open thread on workspace A and keep it generating. let connection_a = StubAgentConnection::new(); open_thread_with_connection(&panel_a, connection_a.clone(), cx); send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + save_test_thread_metadata(&session_id_a, &project_a, cx).await; cx.update(|_, cx| { connection_a.send_update( @@ -1358,11 +1379,9 @@ fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualT async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - for (id, title, hour) in [ ("t-1", "Fix crash in project panel", 3), ("t-2", "Add inline diff view", 2), @@ -1373,7 +1392,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list.clone(), + &project, cx, ); } @@ -1411,17 +1430,15 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { // Search should match case-insensitively so they can still find it. let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("thread-1")), "Fix Crash In Project Panel".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); cx.run_until_parked(); @@ -1453,18 +1470,16 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex // to dismiss the filter and see the full list again. let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list.clone(), + &project, cx, ) } @@ -1502,11 +1517,9 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { let project_a = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), @@ -1516,7 +1529,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list_a.clone(), + &project_a, cx, ) } @@ -1527,7 +1540,8 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC }); cx.run_until_parked(); - let path_list_b = PathList::new::(&[]); + let project_b = + multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone()); for (id, title, hour) in [ ("b1", "Refactor sidebar layout", 3), @@ -1538,7 +1552,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list_b.clone(), + &project_b, cx, ) } @@ -1584,11 +1598,9 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { let project_a = init_test_project("/alpha-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), @@ -1598,7 +1610,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list_a.clone(), + &project_a, cx, ) } @@ -1609,7 +1621,8 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { }); cx.run_until_parked(); - let path_list_b = PathList::new::(&[]); + let project_b = + multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone()); for (id, title, hour) in [ ("b1", "Refactor sidebar layout", 3), @@ -1620,7 +1633,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list_b.clone(), + &project_b, cx, ) } @@ -1686,11 +1699,9 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create 8 threads. The oldest one has a unique name and will be // behind View More (only 5 shown by default). for i in 0..8u32 { @@ -1704,7 +1715,7 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), None, - path_list.clone(), + &project, cx, ) } @@ -1738,17 +1749,15 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("thread-1")), "Important thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); cx.run_until_parked(); @@ -1779,11 +1788,9 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - for (id, title, hour) in [ ("t-1", "Fix crash in panel", 3), ("t-2", "Fix lint warnings", 2), @@ -1794,7 +1801,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), None, - path_list.clone(), + &project, cx, ) } @@ -1841,7 +1848,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); multi_workspace.update_in(cx, |mw, window, cx| { @@ -1849,14 +1856,12 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC }); cx.run_until_parked(); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("hist-1")), "Historical Thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); cx.run_until_parked(); @@ -1899,17 +1904,15 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("t-1")), "Thread A".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), None, - path_list.clone(), + &project, cx, ); @@ -1918,7 +1921,7 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo "Thread B".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); @@ -1966,8 +1969,6 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Hi there!".into()), @@ -1976,7 +1977,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) send_message(&panel, cx); let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + save_test_thread_metadata(&session_id, &project, cx).await; cx.run_until_parked(); assert_eq!( @@ -2014,8 +2015,6 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - // Save a thread so it appears in the list. let connection_a = StubAgentConnection::new(); connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( @@ -2024,7 +2023,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { open_thread_with_connection(&panel_a, connection_a, cx); send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + save_test_thread_metadata(&session_id_a, &project_a, cx).await; // Add a second workspace with its own agent panel. let fs = cx.update(|_, cx| ::global(cx)); @@ -2099,8 +2098,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { open_thread_with_connection(&panel_b, connection_b, cx); send_message(&panel_b, cx); let session_id_b = active_session_id(&panel_b, cx); - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; + save_test_thread_metadata(&session_id_b, &project_b, cx).await; cx.run_until_parked(); // Workspace A is currently active. Click a thread in workspace B, @@ -2161,7 +2159,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { open_thread_with_connection(&panel_b, connection_b2, cx); send_message(&panel_b, cx); let session_id_b2 = active_session_id(&panel_b, cx); - save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; + save_test_thread_metadata(&session_id_b2, &project_b, cx).await; cx.run_until_parked(); // Panel B is not the active workspace's panel (workspace A is @@ -2243,8 +2241,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - // Start a thread and send a message so it has history. let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( @@ -2253,7 +2249,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex open_thread_with_connection(&panel, connection, cx); send_message(&panel, cx); let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; + save_test_thread_metadata(&session_id, &project, cx).await; cx.run_until_parked(); // Verify the thread appears in the sidebar. @@ -2287,9 +2283,15 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // The workspace path_list is now [project-a, project-b]. The active // thread's metadata was re-saved with the new paths by the agent panel's // project subscription, so it stays visible under the updated group. + // The old [project-a] group persists in the sidebar (empty) because + // project_group_keys is append-only. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a, project-b]", " Hello *",] + vec![ + "v [project-a, project-b]", // + " Hello *", + "v [project-a]", + ] ); // The "New Thread" button must still be clickable (not stuck in @@ -2334,8 +2336,6 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create a non-empty thread (has messages). let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( @@ -2345,7 +2345,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { send_message(&panel, cx); let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + save_test_thread_metadata(&session_id, &project, cx).await; cx.run_until_parked(); assert_eq!( @@ -2365,8 +2365,8 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"], - "After Cmd-N the sidebar should show a highlighted New Thread entry" + vec!["v [my-project]", " [~ Draft]", " Hello *"], + "After Cmd-N the sidebar should show a highlighted Draft entry" ); sidebar.read_with(cx, |sidebar, _cx| { @@ -2385,8 +2385,6 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create a saved thread so the workspace has history. let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( @@ -2395,7 +2393,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) open_thread_with_connection(&panel, connection, cx); send_message(&panel, cx); let saved_session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&saved_session_id, path_list.clone(), cx).await; + save_test_thread_metadata(&saved_session_id, &project, cx).await; cx.run_until_parked(); assert_eq!( @@ -2412,8 +2410,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"], - "Draft with a server session should still show as [+ New Thread]" + vec!["v [my-project]", " [~ Draft]", " Hello *"], ); let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); @@ -2503,17 +2500,12 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp send_message(&worktree_panel, cx); let session_id = active_session_id(&worktree_panel, cx); - let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_path_list, cx).await; + save_test_thread_metadata(&session_id, &worktree_project, cx).await; cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} *" - ] + vec!["v [project]", " Hello {wt-feature-a} *"] ); // Simulate Cmd-N in the worktree workspace. @@ -2529,12 +2521,11 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", - " [+ New Thread]", - " [+ New Thread {wt-feature-a}]", + " [~ Draft {wt-feature-a}]", " Hello {wt-feature-a} *" ], "After Cmd-N in an absorbed worktree, the sidebar should show \ - a highlighted New Thread entry under the main repo header" + a highlighted Draft entry under the main repo header" ); sidebar.read_with(cx, |sidebar, _cx| { @@ -2586,14 +2577,17 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { .update(cx, |project, cx| project.git_scans_complete(cx)) .await; + let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; + save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -2615,13 +2609,17 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { .update(cx, |project, cx| project.git_scans_complete(cx)) .await; + let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread against a worktree path that doesn't exist yet. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -2650,11 +2648,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Worktree Thread {rosewood}", - ] + vec!["v [project]", " Worktree Thread {rosewood}",] ); } @@ -2714,10 +2708,8 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC }); let sidebar = setup_sidebar(&multi_workspace, cx); - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -2748,7 +2740,6 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", - " [+ New Thread]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", ] @@ -2813,8 +2804,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut let sidebar = setup_sidebar(&multi_workspace, cx); // Only save a thread for workspace A. - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -2894,11 +2884,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread under the same paths as the workspace roots. - let thread_paths = PathList::new(&[ - std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), - std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), - ]); - save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -2971,11 +2957,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext let sidebar = setup_sidebar(&multi_workspace, cx); // Thread with roots in both repos' "olivetti" worktrees. - let thread_paths = PathList::new(&[ - std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), - std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), - ]); - save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -3070,8 +3052,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp let session_id = active_session_id(&worktree_panel, cx); // Save metadata so the sidebar knows about this thread. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; + save_test_thread_metadata(&session_id, &worktree_project, cx).await; // Keep the thread generating by sending a chunk without ending // the turn. @@ -3091,7 +3072,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp entries, vec![ "v [project]", - " [+ New Thread]", + " [~ Draft]", " Hello {wt-feature-a} * (running)", ] ); @@ -3164,8 +3145,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp send_message(&worktree_panel, cx); let session_id = active_session_id(&worktree_panel, cx); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; + save_test_thread_metadata(&session_id, &worktree_project, cx).await; cx.update(|_, cx| { connection.send_update( @@ -3180,7 +3160,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", - " [+ New Thread]", + " [~ Draft]", " Hello {wt-feature-a} * (running)", ] ); @@ -3190,11 +3170,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (!)", - ] + vec!["v [project]", " [~ Draft]", " Hello {wt-feature-a} * (!)",] ); } @@ -3232,13 +3208,17 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut .update(cx, |p, cx| p.git_scans_complete(cx)) .await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread for the worktree path (no workspace for it). - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -3246,11 +3226,7 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut // Thread should appear under the main repo with a worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " WT Thread {wt-feature-a}" - ], + vec!["v [project]", " WT Thread {wt-feature-a}"], ); // Only 1 workspace should exist. @@ -3262,7 +3238,7 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut // Focus the sidebar and select the worktree thread. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(2); // index 0 is header, 1 is new thread, 2 is the thread + sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); // Confirm to open the worktree thread. @@ -3323,28 +3299,28 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje .update(cx, |p, cx| p.git_scans_complete(cx)) .await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " WT Thread {wt-feature-a}" - ], + vec!["v [project]", " WT Thread {wt-feature-a}"], ); open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(2); + sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { @@ -3400,7 +3376,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje ListEntry::ViewMore { .. } => { panic!("unexpected `View More` entry while opening linked worktree thread"); } - ListEntry::NewThread { .. } => {} + ListEntry::DraftThread { .. } | ListEntry::NewThread { .. } => {} } } @@ -3480,10 +3456,8 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( let sidebar = setup_sidebar(&multi_workspace, cx); - let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -3544,18 +3518,17 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); + mw.test_add_workspace(project_b.clone(), window, cx); }); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread with path_list pointing to project-b. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-1")); - save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; + save_test_thread_metadata(&session_id, &project_b, cx).await; // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { @@ -4093,7 +4066,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon "Thread 2".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), None, - PathList::new(&[std::path::PathBuf::from("/project")]), + &main_project, cx, ); @@ -4105,7 +4078,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon "Thread 1".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + &worktree_project, cx, ); @@ -4215,6 +4188,11 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test .update(cx, |p, cx| p.git_scans_complete(cx)) .await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx)); multi_workspace.update_in(cx, |mw, window, cx| { @@ -4223,8 +4201,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread under the linked worktree path. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4234,11 +4211,10 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ - "v [project]", - " [+ New Thread]", - " Worktree Thread {wt-feature-a}", "v [other, project]", " [+ New Thread]", + "v [project]", + " Worktree Thread {wt-feature-a}", ] ); } @@ -4250,8 +4226,6 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let switcher_ids = |sidebar: &Entity, cx: &mut gpui::VisualTestContext| -> Vec { sidebar.read_with(cx, |sidebar, cx| { @@ -4298,7 +4272,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { "Thread C".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()), - path_list.clone(), + &project, cx, ); @@ -4314,7 +4288,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { "Thread B".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()), - path_list.clone(), + &project, cx, ); @@ -4330,7 +4304,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { "Thread A".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()), - path_list.clone(), + &project, cx, ); @@ -4516,7 +4490,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { "Historical Thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()), - path_list.clone(), + &project, cx, ); @@ -4557,7 +4531,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { "Old Historical Thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(), Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()), - path_list, + &project, cx, ); @@ -4591,17 +4565,15 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("thread-to-archive")), "Thread To Archive".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); cx.run_until_parked(); @@ -4643,17 +4615,15 @@ async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut Test async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_thread_metadata( acp::SessionId::new(Arc::from("visible-thread")), "Visible Thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), None, - path_list.clone(), + &project, cx, ); @@ -4663,7 +4633,7 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon "Archived Thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), None, - path_list, + &project, cx, ); @@ -4756,18 +4726,21 @@ async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut Tes .update(cx, |p, cx| p.git_scans_complete(cx)) .await; + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { MultiWorkspace::test_new(worktree_project.clone(), window, cx) }); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread against the MAIN repo path. - let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); - save_named_thread_metadata("main-thread", "Main Repo Thread", &main_paths, cx).await; + save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await; // Save a thread against the linked worktree path. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4788,7 +4761,6 @@ async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut Tes mod property_test { use super::*; - use gpui::EntityId; struct UnopenedWorktree { path: String, @@ -4922,7 +4894,7 @@ mod property_test { fn save_thread_to_path( state: &mut TestState, - path_list: PathList, + project: &Entity, cx: &mut gpui::VisualTestContext, ) { let session_id = state.next_thread_id(); @@ -4930,7 +4902,7 @@ mod property_test { let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) .unwrap() + chrono::Duration::seconds(state.thread_counter as i64); - save_thread_metadata(session_id, title, updated_at, None, path_list, cx); + save_thread_metadata(session_id, title, updated_at, None, project, cx); } fn save_thread_to_path_with_main( @@ -4970,11 +4942,10 @@ mod property_test { ) { match operation { Operation::SaveThread { workspace_index } => { - let workspace = - multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); - let path_list = workspace - .read_with(cx, |workspace, cx| PathList::new(&workspace.root_paths(cx))); - save_thread_to_path(state, path_list, cx); + let project = multi_workspace.read_with(cx, |mw, cx| { + mw.workspaces()[workspace_index].read(cx).project().clone() + }); + save_thread_to_path(state, &project, cx); } Operation::SaveWorktreeThread { worktree_index } => { let worktree = &state.unopened_worktrees[worktree_index]; @@ -5147,7 +5118,7 @@ mod property_test { .entries .iter() .filter_map(|entry| match entry { - ListEntry::ProjectHeader { path_list, .. } => Some(path_list.clone()), + ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()), _ => None, }) .collect(); @@ -5173,31 +5144,32 @@ mod property_test { anyhow::bail!("sidebar should still have an associated multi-workspace"); }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let mw = multi_workspace.read(cx); - // Workspaces with no root paths are not shown because the - // sidebar skips empty path lists. All other workspaces should - // appear — either via a Thread entry or a NewThread entry for - // threadless workspaces. - let expected_workspaces: HashSet = workspaces - .iter() - .filter(|ws| !workspace_path_list(ws, cx).paths().is_empty()) - .map(|ws| ws.entity_id()) + // Every project group key in the multi-workspace that has a + // non-empty path list should appear as a ProjectHeader in the + // sidebar. + let expected_keys: HashSet<&project::ProjectGroupKey> = mw + .project_group_keys() + .filter(|k| !k.path_list().paths().is_empty()) .collect(); - let sidebar_workspaces: HashSet = sidebar + let sidebar_keys: HashSet<&project::ProjectGroupKey> = sidebar .contents .entries .iter() - .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id())) + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { key, .. } => Some(key), + _ => None, + }) .collect(); - let missing = &expected_workspaces - &sidebar_workspaces; - let stray = &sidebar_workspaces - &expected_workspaces; + let missing = &expected_keys - &sidebar_keys; + let stray = &sidebar_keys - &expected_keys; anyhow::ensure!( missing.is_empty() && stray.is_empty(), - "sidebar workspaces don't match multi-workspace.\n\ + "sidebar project groups don't match multi-workspace.\n\ Only in multi-workspace (missing): {:?}\n\ Only in sidebar (stray): {:?}", missing, @@ -5222,33 +5194,79 @@ mod property_test { .collect(); let mut metadata_thread_ids: HashSet = HashSet::default(); + + // Query using the same approach as the sidebar: iterate project + // group keys, then do main + legacy queries per group. + let mw = multi_workspace.read(cx); + let mut workspaces_by_group: HashMap>> = + HashMap::default(); for workspace in &workspaces { - let path_list = workspace_path_list(workspace, cx); + let key = workspace.read(cx).project_group_key(cx); + workspaces_by_group + .entry(key) + .or_default() + .push(workspace.clone()); + } + + for group_key in mw.project_group_keys() { + let path_list = group_key.path_list().clone(); if path_list.paths().is_empty() { continue; } + + let group_workspaces = workspaces_by_group + .get(group_key) + .map(|ws| ws.as_slice()) + .unwrap_or_default(); + + // Main code path queries (run for all groups, even without workspaces). + for metadata in thread_store + .read(cx) + .entries_for_main_worktree_path(&path_list) + { + metadata_thread_ids.insert(metadata.session_id.clone()); + } for metadata in thread_store.read(cx).entries_for_path(&path_list) { metadata_thread_ids.insert(metadata.session_id.clone()); } - for snapshot in root_repository_snapshots(workspace, cx) { - for linked_worktree in snapshot.linked_worktrees() { - let worktree_path_list = - PathList::new(std::slice::from_ref(&linked_worktree.path)); - for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) { + + // Legacy: per-workspace queries for different root paths. + let covered_paths: HashSet = group_workspaces + .iter() + .flat_map(|ws| { + ws.read(cx) + .root_paths(cx) + .into_iter() + .map(|p| p.to_path_buf()) + }) + .collect(); + + for workspace in group_workspaces { + let ws_path_list = workspace_path_list(workspace, cx); + if ws_path_list != path_list { + for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) { metadata_thread_ids.insert(metadata.session_id.clone()); } } - if snapshot.is_linked_worktree() { - let main_path_list = - PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path)); - for metadata in thread_store.read(cx).entries_for_path(&main_path_list) { - metadata_thread_ids.insert(metadata.session_id.clone()); + } + + for workspace in group_workspaces { + for snapshot in root_repository_snapshots(workspace, cx) { + let repo_path_list = + PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]); + if repo_path_list != path_list { + continue; } - for metadata in thread_store - .read(cx) - .entries_for_main_worktree_path(&main_path_list) - { - metadata_thread_ids.insert(metadata.session_id.clone()); + for linked_worktree in snapshot.linked_worktrees() { + if covered_paths.contains(&*linked_worktree.path) { + continue; + } + let worktree_path_list = + PathList::new(std::slice::from_ref(&linked_worktree.path)); + for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) + { + metadata_thread_ids.insert(metadata.session_id.clone()); + } } } } diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 2f74d84e500e5151014aa2a71686cd68ac3a87a5..25fde261f106d57eef94c4d2ef7cad57b3a7ecd0 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -127,11 +127,11 @@ pub enum HideStrategy { #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SaveStrategy { - #[default] /// Save all edited buffers. All, /// Save the current buffer. Current, + #[default] /// Don't save any buffers. None, } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 0c9bbcbec32dcd0fbb8240d524b83f461ac778c3..3ecc6c844db834da91e2f24c3f0cf2d460b5f246 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1820,6 +1820,7 @@ impl SearchableItem for TerminalView { regex: true, replacement: false, selection: false, + select_all: false, find_in_results: false, } } diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index ba7f600fb05cc160f8d2668cf549853c8ae39ebe..a739df3213d297ce8230cfb62a08c91928bd62df 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -357,7 +357,7 @@ pub(crate) fn zed_default_dark() -> Theme { ("number".into(), orange.into()), ("operator".into(), HighlightStyle::default()), ("predictive".into(), HighlightStyle::default()), - ("preproc".into(), HighlightStyle::default()), + ("preproc".into(), purple.into()), ("primary".into(), HighlightStyle::default()), ("property".into(), red.into()), ("punctuation".into(), HighlightStyle::default()), @@ -377,6 +377,8 @@ pub(crate) fn zed_default_dark() -> Theme { ("variable".into(), HighlightStyle::default()), ("variable.special".into(), red.into()), ("variant".into(), HighlightStyle::default()), + ("diff.plus".into(), green.into()), + ("diff.minus".into(), red.into()), ])), }, } diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 68b1ff9beb7a8918ee3f5e1857e3cc68e15a3fc1..367d80d79c9af8722091e36c8e04bafb7ef0d8b5 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -29,6 +29,7 @@ mod notification; mod popover; mod popover_menu; mod progress; +mod redistributable_columns; mod right_click_menu; mod scrollbar; mod stack; @@ -73,6 +74,7 @@ pub use notification::*; pub use popover::*; pub use popover_menu::*; pub use progress::*; +pub use redistributable_columns::*; pub use right_click_menu::*; pub use scrollbar::*; pub use stack::*; diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index d6b5f56e0abb33521ae69acc0b61b36b015cf987..7658946b6395d6314d90db52716020a922c85ccc 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,7 +1,4 @@ -use crate::{ - CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, - IconDecorationKind, Tooltip, prelude::*, -}; +use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*}; use gpui::{ Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString, @@ -218,7 +215,7 @@ impl RenderOnce for ThreadItem { let color = cx.theme().colors(); let sidebar_base_bg = color .title_bar_background - .blend(color.panel_background.opacity(0.32)); + .blend(color.panel_background.opacity(0.25)); let raw_bg = self.base_bg.unwrap_or(sidebar_base_bg); let apparent_bg = color.background.blend(raw_bg); @@ -266,31 +263,31 @@ impl RenderOnce for ThreadItem { Icon::new(self.icon).color(icon_color).size(IconSize::Small) }; - let decoration = |icon: IconDecorationKind, color: Hsla| { - IconDecoration::new(icon, base_bg, cx) - .color(color) - .position(gpui::Point { - x: px(-2.), - y: px(-2.), - }) - }; - - let (decoration, icon_tooltip) = if self.status == AgentThreadStatus::Error { + let (status_icon, icon_tooltip) = if self.status == AgentThreadStatus::Error { ( - Some(decoration(IconDecorationKind::X, cx.theme().status().error)), + Some( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ), Some("Thread has an Error"), ) } else if self.status == AgentThreadStatus::WaitingForConfirmation { ( - Some(decoration( - IconDecorationKind::Triangle, - cx.theme().status().warning, - )), + Some( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ), Some("Thread is Waiting for Confirmation"), ) } else if self.notified { ( - Some(decoration(IconDecorationKind::Dot, color.text_accent)), + Some( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Accent), + ), Some("Thread's Generation is Complete"), ) } else { @@ -306,9 +303,9 @@ impl RenderOnce for ThreadItem { .with_rotate_animation(2), ) .into_any_element() - } else if let Some(decoration) = decoration { + } else if let Some(status_icon) = status_icon { icon_container() - .child(DecoratedIcon::new(agent_icon, Some(decoration))) + .child(status_icon) .when_some(icon_tooltip, |icon, tooltip| { icon.tooltip(Tooltip::text(tooltip)) }) @@ -551,12 +548,17 @@ impl Component for ThreadItem { } fn preview(_window: &mut Window, cx: &mut App) -> Option { + let color = cx.theme().colors(); + let bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.25)); + let container = || { v_flex() .w_72() .border_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().panel_background) + .border_color(color.border_variant) + .bg(bg) }; let thread_item_examples = vec![ @@ -570,16 +572,6 @@ impl Component for ThreadItem { ) .into_any_element(), ), - single_example( - "Timestamp Only (hours)", - container() - .child( - ThreadItem::new("ti-1b", "Thread with just a timestamp") - .icon(IconName::AiClaude) - .timestamp("3h"), - ) - .into_any_element(), - ), single_example( "Notified (weeks)", container() diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 2012defc47d9cccea87849fa41470ad1183b552f..e5a14a3ddabc0d918bfe6d6bcb077e32adeb6eb4 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1,19 +1,19 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle, - Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful, - UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list, + DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, + ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list, + transparent_black, uniform_list, }; -use itertools::intersperse_with; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, - ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, - InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, - ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, - StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex, - px, single_example, + ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, + Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState, + RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, + StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar, + bind_redistributable_columns, div, example_group_with_title, h_flex, px, + render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -22,16 +22,10 @@ pub mod table_row; #[cfg(test)] mod tests; -const RESIZE_COLUMN_WIDTH: f32 = 8.0; -const RESIZE_DIVIDER_WIDTH: f32 = 1.0; - /// Represents an unchecked table row, which is a vector of elements. /// Will be converted into `TableRow` internally pub type UncheckedTableRow = Vec; -#[derive(Debug)] -pub(crate) struct DraggedColumn(pub(crate) usize); - struct UniformListData { render_list_of_rows_fn: Box, &mut Window, &mut App) -> Vec>>, @@ -113,124 +107,6 @@ impl TableInteractionState { } } -/// Renders invisible resize handles overlaid on top of table content. -/// -/// - Spacer: invisible element that matches the width of table column content -/// - Divider: contains the actual resize handle that users can drag to resize columns -/// -/// Structure: [spacer] [divider] [spacer] [divider] [spacer] -/// -/// Business logic: -/// 1. Creates spacers matching each column width -/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns) -/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize -/// 4. Returns an absolute-positioned overlay that sits on top of table content -fn render_resize_handles( - column_widths: &TableRow, - resizable_columns: &TableRow, - initial_sizes: &TableRow, - columns: Option>, - window: &mut Window, - cx: &mut App, -) -> AnyElement { - let spacers = column_widths - .as_slice() - .iter() - .map(|width| base_cell_style(Some(*width)).into_any_element()); - - let mut column_ix = 0; - let resizable_columns_shared = Rc::new(resizable_columns.clone()); - let initial_sizes_shared = Rc::new(initial_sizes.clone()); - let mut resizable_columns_iter = resizable_columns.as_slice().iter(); - - let dividers = intersperse_with(spacers, || { - let resizable_columns = Rc::clone(&resizable_columns_shared); - let initial_sizes = Rc::clone(&initial_sizes_shared); - window.with_id(column_ix, |window| { - let mut resize_divider = div() - .id(column_ix) - .relative() - .top_0() - .w(px(RESIZE_DIVIDER_WIDTH)) - .h_full() - .bg(cx.theme().colors().border.opacity(0.8)); - - let mut resize_handle = div() - .id("column-resize-handle") - .absolute() - .left_neg_0p5() - .w(px(RESIZE_COLUMN_WIDTH)) - .h_full(); - - if resizable_columns_iter - .next() - .is_some_and(TableResizeBehavior::is_resizable) - { - let hovered = window.use_state(cx, |_window, _cx| false); - - resize_divider = resize_divider.when(*hovered.read(cx), |div| { - div.bg(cx.theme().colors().border_focused) - }); - - resize_handle = resize_handle - .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) - .cursor_col_resize() - .when_some(columns.clone(), |this, columns| { - this.on_click(move |event, window, cx| { - if event.click_count() >= 2 { - columns.update(cx, |columns, _| { - columns.on_double_click( - column_ix, - &initial_sizes, - &resizable_columns, - window, - ); - }) - } - - cx.stop_propagation(); - }) - }) - .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| { - cx.new(|_cx| gpui::Empty) - }) - } - - column_ix += 1; - resize_divider.child(resize_handle).into_any_element() - }) - }); - - h_flex() - .id("resize-handles") - .absolute() - .inset_0() - .w_full() - .children(dividers) - .into_any_element() -} - -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum TableResizeBehavior { - None, - Resizable, - MinSize(f32), -} - -impl TableResizeBehavior { - pub fn is_resizable(&self) -> bool { - *self != TableResizeBehavior::None - } - - pub fn min_size(&self) -> Option { - match self { - TableResizeBehavior::None => None, - TableResizeBehavior::Resizable => Some(0.05), - TableResizeBehavior::MinSize(min_size) => Some(*min_size), - } - } -} - pub enum ColumnWidthConfig { /// Static column widths (no resize handles). Static { @@ -278,6 +154,21 @@ impl ColumnWidthConfig { } } + /// Explicit column widths with no fixed table width. + pub fn explicit>(widths: Vec) -> Self { + let cols = widths.len(); + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Explicit( + widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols), + ), + table_width: None, + } + } + /// Column widths for rendering. pub fn widths_to_render(&self, cx: &App) -> Option> { match self { @@ -292,10 +183,7 @@ impl ColumnWidthConfig { ColumnWidthConfig::Redistributable { columns_state: entity, .. - } => { - let state = entity.read(cx); - Some(state.preview_widths.map_cloned(Length::Definite)) - } + } => Some(entity.read(cx).widths_to_render()), } } @@ -316,296 +204,6 @@ impl ColumnWidthConfig { None => ListHorizontalSizingBehavior::FitList, } } - - /// Render resize handles overlay if applicable. - pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option { - match self { - ColumnWidthConfig::Redistributable { - columns_state: entity, - .. - } => { - let (column_widths, resize_behavior, initial_widths) = { - let state = entity.read(cx); - ( - state.preview_widths.map_cloned(Length::Definite), - state.resize_behavior.clone(), - state.initial_widths.clone(), - ) - }; - Some(render_resize_handles( - &column_widths, - &resize_behavior, - &initial_widths, - Some(entity.clone()), - window, - cx, - )) - } - _ => None, - } - } - - /// Returns info needed for header double-click-to-reset, if applicable. - pub fn header_resize_info(&self, cx: &App) -> Option { - match self { - ColumnWidthConfig::Redistributable { columns_state, .. } => { - let state = columns_state.read(cx); - Some(HeaderResizeInfo { - columns_state: columns_state.downgrade(), - resize_behavior: state.resize_behavior.clone(), - initial_widths: state.initial_widths.clone(), - }) - } - _ => None, - } - } -} - -#[derive(Clone)] -pub struct HeaderResizeInfo { - pub columns_state: WeakEntity, - pub resize_behavior: TableRow, - pub initial_widths: TableRow, -} - -pub struct RedistributableColumnsState { - pub(crate) initial_widths: TableRow, - pub(crate) committed_widths: TableRow, - pub(crate) preview_widths: TableRow, - pub(crate) resize_behavior: TableRow, - pub(crate) cached_table_width: Pixels, -} - -impl RedistributableColumnsState { - pub fn new( - cols: usize, - initial_widths: UncheckedTableRow>, - resize_behavior: UncheckedTableRow, - ) -> Self { - let widths: TableRow = initial_widths - .into_iter() - .map(Into::into) - .collect::>() - .into_table_row(cols); - Self { - initial_widths: widths.clone(), - committed_widths: widths.clone(), - preview_widths: widths, - resize_behavior: resize_behavior.into_table_row(cols), - cached_table_width: Default::default(), - } - } - - pub fn cols(&self) -> usize { - self.committed_widths.cols() - } - - pub fn initial_widths(&self) -> &TableRow { - &self.initial_widths - } - - pub fn resize_behavior(&self) -> &TableRow { - &self.resize_behavior - } - - fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 { - match length { - DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width, - DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { - rems_width.to_pixels(rem_size) / bounds_width - } - DefiniteLength::Fraction(fraction) => *fraction, - } - } - - pub(crate) fn on_double_click( - &mut self, - double_click_position: usize, - initial_sizes: &TableRow, - resize_behavior: &TableRow, - window: &mut Window, - ) { - let bounds_width = self.cached_table_width; - let rem_size = window.rem_size(); - let initial_sizes = - initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - let widths = self - .committed_widths - .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - - let updated_widths = Self::reset_to_initial_size( - double_click_position, - widths, - initial_sizes, - resize_behavior, - ); - self.committed_widths = updated_widths.map(DefiniteLength::Fraction); - self.preview_widths = self.committed_widths.clone(); - } - - pub(crate) fn reset_to_initial_size( - col_idx: usize, - mut widths: TableRow, - initial_sizes: TableRow, - resize_behavior: &TableRow, - ) -> TableRow { - let diff = initial_sizes[col_idx] - widths[col_idx]; - - let left_diff = - initial_sizes[..col_idx].iter().sum::() - widths[..col_idx].iter().sum::(); - let right_diff = initial_sizes[col_idx + 1..].iter().sum::() - - widths[col_idx + 1..].iter().sum::(); - - let go_left_first = if diff < 0.0 { - left_diff > right_diff - } else { - left_diff < right_diff - }; - - if !go_left_first { - let diff_remaining = - Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1); - - if diff_remaining != 0.0 && col_idx > 0 { - Self::propagate_resize_diff( - diff_remaining, - col_idx, - &mut widths, - resize_behavior, - -1, - ); - } - } else { - let diff_remaining = - Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1); - - if diff_remaining != 0.0 { - Self::propagate_resize_diff( - diff_remaining, - col_idx, - &mut widths, - resize_behavior, - 1, - ); - } - } - - widths - } - - pub(crate) fn on_drag_move( - &mut self, - drag_event: &DragMoveEvent, - window: &mut Window, - cx: &mut Context, - ) { - let drag_position = drag_event.event.position; - let bounds = drag_event.bounds; - - let mut col_position = 0.0; - let rem_size = window.rem_size(); - let bounds_width = bounds.right() - bounds.left(); - let col_idx = drag_event.drag(cx).0; - - let divider_width = Self::get_fraction( - &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), - bounds_width, - rem_size, - ); - - let mut widths = self - .committed_widths - .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - - for length in widths[0..=col_idx].iter() { - col_position += length + divider_width; - } - - let mut total_length_ratio = col_position; - for length in widths[col_idx + 1..].iter() { - total_length_ratio += length; - } - let cols = self.resize_behavior.cols(); - total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; - - let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; - let drag_fraction = drag_fraction * total_length_ratio; - let diff = drag_fraction - col_position - divider_width / 2.0; - - Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); - - self.preview_widths = widths.map(DefiniteLength::Fraction); - } - - pub(crate) fn drag_column_handle( - diff: f32, - col_idx: usize, - widths: &mut TableRow, - resize_behavior: &TableRow, - ) { - if diff > 0.0 { - Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1); - } else { - Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1); - } - } - - pub(crate) fn propagate_resize_diff( - diff: f32, - col_idx: usize, - widths: &mut TableRow, - resize_behavior: &TableRow, - direction: i8, - ) -> f32 { - let mut diff_remaining = diff; - if resize_behavior[col_idx].min_size().is_none() { - return diff; - } - - let step_right; - let step_left; - if direction < 0 { - step_right = 0; - step_left = 1; - } else { - step_right = 1; - step_left = 0; - } - if col_idx == 0 && direction < 0 { - return diff; - } - let mut curr_column = col_idx + step_right - step_left; - - while diff_remaining != 0.0 && curr_column < widths.cols() { - let Some(min_size) = resize_behavior[curr_column].min_size() else { - if curr_column == 0 { - break; - } - curr_column -= step_left; - curr_column += step_right; - continue; - }; - - let curr_width = widths[curr_column] - diff_remaining; - widths[curr_column] = curr_width; - - if min_size > curr_width { - diff_remaining = min_size - curr_width; - widths[curr_column] = min_size; - } else { - diff_remaining = 0.0; - break; - } - if curr_column == 0 { - break; - } - curr_column -= step_left; - curr_column += step_right; - } - widths[col_idx] = widths[col_idx] + (diff - diff_remaining); - - diff_remaining - } } /// A table component @@ -919,11 +517,8 @@ pub fn render_table_header( if event.click_count() > 1 { info.columns_state .update(cx, |column, _| { - column.on_double_click( - header_idx, - &info.initial_widths, - &info.resize_behavior, - window, + column.reset_column_to_initial_width( + header_idx, window, ); }) .ok(); @@ -962,6 +557,19 @@ impl TableRenderContext { disable_base_cell_style: table.disable_base_cell_style, } } + + pub fn for_column_widths(column_widths: Option>, use_ui_font: bool) -> Self { + Self { + striped: false, + show_row_borders: true, + show_row_hover: true, + total_row_count: 0, + column_widths, + map_row: None, + use_ui_font, + disable_base_cell_style: false, + } + } } impl RenderOnce for Table { @@ -969,9 +577,15 @@ impl RenderOnce for Table { let table_context = TableRenderContext::new(&self, cx); let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); - let header_resize_info = interaction_state - .as_ref() - .and_then(|_| self.column_width_config.header_resize_info(cx)); + let header_resize_info = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => { + Some(HeaderResizeInfo::from_state(columns_state, cx)) + } + _ => None, + }); let table_width = self.column_width_config.table_width(); let horizontal_sizing = self.column_width_config.list_horizontal_sizing(); @@ -985,13 +599,19 @@ impl RenderOnce for Table { ColumnWidthConfig::Redistributable { columns_state: entity, .. - } => Some(entity.downgrade()), + } => Some(entity.clone()), _ => None, }); - let resize_handles = interaction_state - .as_ref() - .and_then(|_| self.column_width_config.render_resize_handles(window, cx)); + let resize_handles = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => Some( + render_redistributable_columns_resize_handles(columns_state, window, cx), + ), + _ => None, + }); let table = div() .when_some(table_width, |this, width| this.w(width)) @@ -1006,38 +626,8 @@ impl RenderOnce for Table { cx, )) }) - .when_some(redistributable_entity, { - |this, widths| { - this.on_drag_move::({ - let widths = widths.clone(); - move |e, window, cx| { - widths - .update(cx, |widths, cx| { - widths.on_drag_move(e, window, cx); - }) - .ok(); - } - }) - .on_children_prepainted({ - let widths = widths.clone(); - move |bounds, _, cx| { - widths - .update(cx, |widths, _| { - // This works because all children x axis bounds are the same - widths.cached_table_width = - bounds[0].right() - bounds[0].left(); - }) - .ok(); - } - }) - .on_drop::(move |_, _, cx| { - widths - .update(cx, |widths, _| { - widths.committed_widths = widths.preview_widths.clone(); - }) - .ok(); - }) - } + .when_some(redistributable_entity, |this, widths| { + bind_redistributable_columns(this, widths) }) .child({ let content = div() diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs index 0936cd3088cc50bc08bf0a0a09d9a6fa7a2cdaf0..604e8b7cd1aabee85b406ec99d458c949eda599b 100644 --- a/crates/ui/src/components/data_table/tests.rs +++ b/crates/ui/src/components/data_table/tests.rs @@ -1,4 +1,5 @@ -use super::*; +use super::table_row::TableRow; +use crate::{RedistributableColumnsState, TableResizeBehavior}; fn is_almost_eq(a: &[f32], b: &[f32]) -> bool { a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd22c31e19736e72e5d88676178053b49a3e65fd --- /dev/null +++ b/crates/ui/src/components/redistributable_columns.rs @@ -0,0 +1,485 @@ +use std::rc::Rc; + +use gpui::{ + AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length, + WeakEntity, +}; +use itertools::intersperse_with; + +use super::data_table::table_row::{IntoTableRow as _, TableRow}; +use crate::{ + ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement, + IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex, + px, +}; + +const RESIZE_COLUMN_WIDTH: f32 = 8.0; +const RESIZE_DIVIDER_WIDTH: f32 = 1.0; + +#[derive(Debug)] +struct DraggedColumn(usize); + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum TableResizeBehavior { + None, + Resizable, + MinSize(f32), +} + +impl TableResizeBehavior { + pub fn is_resizable(&self) -> bool { + *self != TableResizeBehavior::None + } + + pub fn min_size(&self) -> Option { + match self { + TableResizeBehavior::None => None, + TableResizeBehavior::Resizable => Some(0.05), + TableResizeBehavior::MinSize(min_size) => Some(*min_size), + } + } +} + +#[derive(Clone)] +pub struct HeaderResizeInfo { + pub columns_state: WeakEntity, + pub resize_behavior: TableRow, +} + +impl HeaderResizeInfo { + pub fn from_state(columns_state: &Entity, cx: &App) -> Self { + let resize_behavior = columns_state.read(cx).resize_behavior().clone(); + Self { + columns_state: columns_state.downgrade(), + resize_behavior, + } + } +} + +pub struct RedistributableColumnsState { + pub(crate) initial_widths: TableRow, + pub(crate) committed_widths: TableRow, + pub(crate) preview_widths: TableRow, + pub(crate) resize_behavior: TableRow, + pub(crate) cached_container_width: Pixels, +} + +impl RedistributableColumnsState { + pub fn new( + cols: usize, + initial_widths: Vec>, + resize_behavior: Vec, + ) -> Self { + let widths: TableRow = initial_widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols); + Self { + initial_widths: widths.clone(), + committed_widths: widths.clone(), + preview_widths: widths, + resize_behavior: resize_behavior.into_table_row(cols), + cached_container_width: Default::default(), + } + } + + pub fn cols(&self) -> usize { + self.committed_widths.cols() + } + + pub fn initial_widths(&self) -> &TableRow { + &self.initial_widths + } + + pub fn preview_widths(&self) -> &TableRow { + &self.preview_widths + } + + pub fn resize_behavior(&self) -> &TableRow { + &self.resize_behavior + } + + pub fn widths_to_render(&self) -> TableRow { + self.preview_widths.map_cloned(Length::Definite) + } + + pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow { + if self.cached_container_width > px(0.) { + self.preview_widths + .map_ref(|length| Self::get_fraction(length, self.cached_container_width, rem_size)) + } else { + self.preview_widths.map_ref(|length| match length { + DefiniteLength::Fraction(fraction) => *fraction, + DefiniteLength::Absolute(_) => 0.0, + }) + } + } + + pub fn preview_column_width(&self, column_index: usize, window: &Window) -> Option { + let width = self.preview_widths().as_slice().get(column_index)?; + match width { + DefiniteLength::Fraction(fraction) if self.cached_container_width > px(0.) => { + Some(self.cached_container_width * *fraction) + } + DefiniteLength::Fraction(_) => None, + DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => Some(*pixels), + DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { + Some(rems_width.to_pixels(window.rem_size())) + } + } + } + + pub fn cached_container_width(&self) -> Pixels { + self.cached_container_width + } + + pub fn set_cached_container_width(&mut self, width: Pixels) { + self.cached_container_width = width; + } + + pub fn commit_preview(&mut self) { + self.committed_widths = self.preview_widths.clone(); + } + + pub fn reset_column_to_initial_width(&mut self, column_index: usize, window: &Window) { + let bounds_width = self.cached_container_width; + if bounds_width <= px(0.) { + return; + } + + let rem_size = window.rem_size(); + let initial_sizes = self + .initial_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + let widths = self + .committed_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + + let updated_widths = + Self::reset_to_initial_size(column_index, widths, initial_sizes, &self.resize_behavior); + self.committed_widths = updated_widths.map(DefiniteLength::Fraction); + self.preview_widths = self.committed_widths.clone(); + } + + fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 { + match length { + DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width, + DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { + rems_width.to_pixels(rem_size) / bounds_width + } + DefiniteLength::Fraction(fraction) => *fraction, + } + } + + pub(crate) fn reset_to_initial_size( + col_idx: usize, + mut widths: TableRow, + initial_sizes: TableRow, + resize_behavior: &TableRow, + ) -> TableRow { + let diff = initial_sizes[col_idx] - widths[col_idx]; + + let left_diff = + initial_sizes[..col_idx].iter().sum::() - widths[..col_idx].iter().sum::(); + let right_diff = initial_sizes[col_idx + 1..].iter().sum::() + - widths[col_idx + 1..].iter().sum::(); + + let go_left_first = if diff < 0.0 { + left_diff > right_diff + } else { + left_diff < right_diff + }; + + if !go_left_first { + let diff_remaining = + Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1); + + if diff_remaining != 0.0 && col_idx > 0 { + Self::propagate_resize_diff( + diff_remaining, + col_idx, + &mut widths, + resize_behavior, + -1, + ); + } + } else { + let diff_remaining = + Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1); + + if diff_remaining != 0.0 { + Self::propagate_resize_diff( + diff_remaining, + col_idx, + &mut widths, + resize_behavior, + 1, + ); + } + } + + widths + } + + fn on_drag_move( + &mut self, + drag_event: &DragMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let drag_position = drag_event.event.position; + let bounds = drag_event.bounds; + let bounds_width = bounds.right() - bounds.left(); + if bounds_width <= px(0.) { + return; + } + + let mut col_position = 0.0; + let rem_size = window.rem_size(); + let col_idx = drag_event.drag(cx).0; + + let divider_width = Self::get_fraction( + &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), + bounds_width, + rem_size, + ); + + let mut widths = self + .committed_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + + for length in widths[0..=col_idx].iter() { + col_position += length + divider_width; + } + + let mut total_length_ratio = col_position; + for length in widths[col_idx + 1..].iter() { + total_length_ratio += length; + } + let cols = self.resize_behavior.cols(); + total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; + + let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; + let drag_fraction = drag_fraction * total_length_ratio; + let diff = drag_fraction - col_position - divider_width / 2.0; + + Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); + + self.preview_widths = widths.map(DefiniteLength::Fraction); + } + + pub(crate) fn drag_column_handle( + diff: f32, + col_idx: usize, + widths: &mut TableRow, + resize_behavior: &TableRow, + ) { + if diff > 0.0 { + Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1); + } else { + Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1); + } + } + + pub(crate) fn propagate_resize_diff( + diff: f32, + col_idx: usize, + widths: &mut TableRow, + resize_behavior: &TableRow, + direction: i8, + ) -> f32 { + let mut diff_remaining = diff; + if resize_behavior[col_idx].min_size().is_none() { + return diff; + } + + let step_right; + let step_left; + if direction < 0 { + step_right = 0; + step_left = 1; + } else { + step_right = 1; + step_left = 0; + } + if col_idx == 0 && direction < 0 { + return diff; + } + let mut curr_column = col_idx + step_right - step_left; + + while diff_remaining != 0.0 && curr_column < widths.cols() { + let Some(min_size) = resize_behavior[curr_column].min_size() else { + if curr_column == 0 { + break; + } + curr_column -= step_left; + curr_column += step_right; + continue; + }; + + let curr_width = widths[curr_column] - diff_remaining; + widths[curr_column] = curr_width; + + if min_size > curr_width { + diff_remaining = min_size - curr_width; + widths[curr_column] = min_size; + } else { + diff_remaining = 0.0; + break; + } + if curr_column == 0 { + break; + } + curr_column -= step_left; + curr_column += step_right; + } + widths[col_idx] = widths[col_idx] + (diff - diff_remaining); + + diff_remaining + } +} + +pub fn bind_redistributable_columns( + container: Div, + columns_state: Entity, +) -> Div { + container + .on_drag_move::({ + let columns_state = columns_state.clone(); + move |event, window, cx| { + columns_state.update(cx, |columns, cx| { + columns.on_drag_move(event, window, cx); + }); + } + }) + .on_children_prepainted({ + let columns_state = columns_state.clone(); + move |bounds, _, cx| { + if let Some(width) = child_bounds_width(&bounds) { + columns_state.update(cx, |columns, _| { + columns.set_cached_container_width(width); + }); + } + } + }) + .on_drop::(move |_, _, cx| { + columns_state.update(cx, |columns, _| { + columns.commit_preview(); + }); + }) +} + +pub fn render_redistributable_columns_resize_handles( + columns_state: &Entity, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let (column_widths, resize_behavior) = { + let state = columns_state.read(cx); + (state.widths_to_render(), state.resize_behavior().clone()) + }; + + let mut column_ix = 0; + let resize_behavior = Rc::new(resize_behavior); + let dividers = intersperse_with( + column_widths + .as_slice() + .iter() + .copied() + .map(|width| resize_spacer(width).into_any_element()), + || { + let current_column_ix = column_ix; + let resize_behavior = Rc::clone(&resize_behavior); + let columns_state = columns_state.clone(); + column_ix += 1; + + window.with_id(current_column_ix, |window| { + let mut resize_divider = div() + .id(current_column_ix) + .relative() + .top_0() + .w(px(RESIZE_DIVIDER_WIDTH)) + .h_full() + .bg(cx.theme().colors().border.opacity(0.8)); + + let mut resize_handle = div() + .id("column-resize-handle") + .absolute() + .left_neg_0p5() + .w(px(RESIZE_COLUMN_WIDTH)) + .h_full(); + + if resize_behavior[current_column_ix].is_resizable() { + let is_highlighted = window.use_state(cx, |_window, _cx| false); + + resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| { + div.bg(cx.theme().colors().border_focused) + }); + + resize_handle = resize_handle + .on_hover({ + let is_highlighted = is_highlighted.clone(); + move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered) + }) + .cursor_col_resize() + .on_click({ + let columns_state = columns_state.clone(); + move |event, window, cx| { + if event.click_count() >= 2 { + columns_state.update(cx, |columns, _| { + columns.reset_column_to_initial_width( + current_column_ix, + window, + ); + }); + } + + cx.stop_propagation(); + } + }) + .on_drag(DraggedColumn(current_column_ix), { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }) + .on_drop::(move |_, _, cx| { + is_highlighted.write(cx, false); + columns_state.update(cx, |state, _| { + state.commit_preview(); + }); + }); + } + + resize_divider.child(resize_handle).into_any_element() + }) + }, + ); + + h_flex() + .id("resize-handles") + .absolute() + .inset_0() + .w_full() + .children(dividers) + .into_any_element() +} + +fn resize_spacer(width: Length) -> Div { + div().w(width).h_full() +} + +fn child_bounds_width(bounds: &[Bounds]) -> Option { + let first_bounds = bounds.first()?; + let mut left = first_bounds.left(); + let mut right = first_bounds.right(); + + for bound in bounds.iter().skip(1) { + left = left.min(bound.left()); + right = right.max(bound.right()); + } + + Some(right - left) +} diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 0ea8bce6face2c248239c92e43a14ed010fb0c6e..af99f4c6570b35b004179afb87b737d3a4356489 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -38,7 +38,7 @@ impl Hash for PathList { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializedPathList { pub paths: String, pub order: String, @@ -65,6 +65,16 @@ impl PathList { self.paths.is_empty() } + /// Returns a new `PathList` with the given path removed. + pub fn without_path(&self, path_to_remove: &Path) -> PathList { + let paths: Vec = self + .ordered_paths() + .filter(|p| p.as_path() != path_to_remove) + .cloned() + .collect(); + PathList::new(&paths) + } + /// Get the paths in lexicographic order. pub fn paths(&self) -> &[PathBuf] { self.paths.as_ref() diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 510d218df050455d0df0f9c2b7b782a651694cd7..6f15450aa3f70593c6877c293fecb765978e065d 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -109,12 +109,12 @@ impl VimTestContext { } cx.bind_keys(default_key_bindings); if enabled { - let vim_key_bindings = settings::KeymapFile::load_asset( - "keymaps/vim.json", - Some(settings::KeybindSource::Vim), - cx, - ) - .unwrap(); + let mut vim_key_bindings = + settings::KeymapFile::load_asset_allow_partial_failure("keymaps/vim.json", cx) + .unwrap(); + for key_binding in &mut vim_key_bindings { + key_binding.set_meta(settings::KeybindSource::Vim.meta()); + } cx.bind_keys(vim_key_bindings); } } diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index 17addd24d445a666138a1b37fef872beedd07aed..11227d8fb5c7152dc5b7e03b95fadea6cb714717 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -1,13 +1,13 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; -use client::{Client, UserStore}; +use client::{Client, NeedsLlmTokenRefresh, UserStore, global_llm_token}; use cloud_api_types::OrganizationId; use cloud_llm_client::{WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; use gpui::{App, AppContext, Context, Entity, Task}; use http_client::{HttpClient, Method}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; +use language_model::LlmApiToken; use web_search::{WebSearchProvider, WebSearchProviderId}; pub struct CloudWebSearchProvider { @@ -30,7 +30,7 @@ pub struct State { impl State { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let llm_api_token = LlmApiToken::global(cx); + let llm_api_token = global_llm_token(cx); Self { client, @@ -73,8 +73,8 @@ async fn perform_web_search( let http_client = &client.http_client(); let mut retries_remaining = MAX_RETRIES; - let mut token = llm_api_token - .acquire(&client, organization_id.clone()) + let mut token = client + .acquire_llm_token(&llm_api_token, organization_id.clone()) .await?; loop { @@ -100,8 +100,8 @@ async fn perform_web_search( response.body_mut().read_to_string(&mut body).await?; return Ok(serde_json::from_str(&body)?); } else if response.needs_llm_token_refresh() { - token = llm_api_token - .refresh(&client, organization_id.clone()) + token = client + .refresh_llm_token(&llm_api_token, organization_id.clone()) .await?; retries_remaining -= 1; } else { diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index e36b48f06fd3ca0983b13ddb564af08ddab9fba5..e58b4b59100c05085c93993370b85a788fc159ca 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1,5 +1,6 @@ +use crate::focus_follows_mouse::FocusFollowsMouse as _; use crate::persistence::model::DockData; -use crate::{DraggedDock, Event, ModalLayer, Pane}; +use crate::{DraggedDock, Event, FocusFollowsMouse, ModalLayer, Pane, WorkspaceSettings}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; use client::proto; @@ -12,7 +13,7 @@ use gpui::{ px, }; use serde::{Deserialize, Serialize}; -use settings::SettingsStore; +use settings::{Settings, SettingsStore}; use std::sync::Arc; use ui::{ ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*, @@ -252,6 +253,7 @@ pub struct Dock { is_open: bool, active_panel_index: Option, focus_handle: FocusHandle, + focus_follows_mouse: FocusFollowsMouse, pub(crate) serialized_dock: Option, zoom_layer_open: bool, modal_layer: Entity, @@ -376,6 +378,7 @@ impl Dock { active_panel_index: None, is_open: false, focus_handle: focus_handle.clone(), + focus_follows_mouse: WorkspaceSettings::get_global(cx).focus_follows_mouse, _subscriptions: [focus_subscription, zoom_subscription], serialized_dock: None, zoom_layer_open: false, @@ -1086,8 +1089,10 @@ impl Render for Dock { }; div() + .id("dock-panel") .key_context(dispatch_context) .track_focus(&self.focus_handle(cx)) + .focus_follows_mouse(self.focus_follows_mouse, cx) .flex() .bg(cx.theme().colors().panel_background) .border_color(cx.theme().colors().border) @@ -1121,6 +1126,7 @@ impl Render for Dock { }) } else { div() + .id("dock-panel") .key_context(dispatch_context) .track_focus(&self.focus_handle(cx)) } diff --git a/crates/workspace/src/focus_follows_mouse.rs b/crates/workspace/src/focus_follows_mouse.rs new file mode 100644 index 0000000000000000000000000000000000000000..da433cefcf059960181c190da83b06260651b063 --- /dev/null +++ b/crates/workspace/src/focus_follows_mouse.rs @@ -0,0 +1,71 @@ +use gpui::{ + AnyWindowHandle, AppContext as _, Context, FocusHandle, Focusable, Global, + StatefulInteractiveElement, Task, +}; + +use crate::workspace_settings; + +#[derive(Default)] +struct FfmState { + // The window and element to be focused + handles: Option<(AnyWindowHandle, FocusHandle)>, + // The debounced task which will do the focusing + _debounce_task: Option>, +} + +impl Global for FfmState {} + +pub trait FocusFollowsMouse: StatefulInteractiveElement { + fn focus_follows_mouse( + self, + settings: workspace_settings::FocusFollowsMouse, + cx: &Context, + ) -> Self { + if settings.enabled { + self.on_hover(cx.listener(move |this, enter, window, cx| { + if *enter { + let window_handle = window.window_handle(); + let focus_handle = this.focus_handle(cx); + + let state = cx.try_global::(); + + // Only replace the target if the new handle doesn't contain the existing one. + // This ensures that hovering over a parent (e.g., Dock) doesn't override + // a more specific child target (e.g., a Pane inside the Dock). + let should_replace = state + .and_then(|s| s.handles.as_ref()) + .map(|(_, existing)| !focus_handle.contains(existing, window)) + .unwrap_or(true); + + if !should_replace { + return; + } + + let debounce_task = cx.spawn(async move |_this, cx| { + cx.background_executor().timer(settings.debounce).await; + + cx.update(|cx| { + let state = cx.default_global::(); + let Some((window, focus)) = state.handles.take() else { + return; + }; + + let _ = cx.update_window(window, move |_view, window, cx| { + window.focus(&focus, cx); + }); + }); + }); + + cx.set_global(FfmState { + handles: Some((window_handle, focus_handle)), + _debounce_task: Some(debounce_task), + }); + } + })) + } else { + self + } + } +} + +impl FocusFollowsMouse for T {} diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index ed104a534eba7707a04a60775ae08820c4f258b8..64647419e300357e360e3ac3f535d8bbcd076711 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -9,7 +9,7 @@ use crate::{ }; use anyhow::Result; use client::{Client, proto}; -use futures::{StreamExt, channel::mpsc}; +use futures::channel::mpsc; use gpui::{ Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task, @@ -777,8 +777,8 @@ impl ItemHandle for Entity { send_follower_updates = Some(cx.spawn_in(window, { let pending_update = pending_update.clone(); async move |workspace, cx| { - while let Some(mut leader_id) = pending_update_rx.next().await { - while let Ok(Some(id)) = pending_update_rx.try_next() { + while let Ok(mut leader_id) = pending_update_rx.recv().await { + while let Ok(id) = pending_update_rx.try_recv() { leader_id = id; } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 10a5ce70ead2d5aea7cc21a9af53ee9f216859c3..dc6060b70a0eeeebc1168113c2c9eb1ba2ddd251 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,20 +1,23 @@ use anyhow::Result; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::PathPromptOptions; use gpui::{ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, actions, deferred, px, }; -use project::DisableAiSettings; #[cfg(any(test, feature = "test-support"))] use project::Project; +use project::{DirectoryLister, DisableAiSettings, ProjectGroupKey}; use settings::Settings; pub use settings::SidebarSide; use std::future::Future; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use ui::prelude::*; use util::ResultExt; +use util::path_list::PathList; use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher}; use agent_settings::AgentSettings; @@ -23,9 +26,11 @@ use ui::{ContextMenu, right_click_menu}; const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +use crate::AppState; use crate::{ CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode, Panel, Workspace, WorkspaceId, client_side_decorations, + persistence::model::MultiWorkspaceState, }; actions!( @@ -222,6 +227,7 @@ pub struct MultiWorkspace { window_id: WindowId, workspaces: Vec>, active_workspace_index: usize, + project_group_keys: Vec, sidebar: Option>, sidebar_open: bool, sidebar_overlay: Option, @@ -269,6 +275,7 @@ impl MultiWorkspace { }); Self { window_id: window.window_handle().window_id(), + project_group_keys: vec![workspace.read(cx).project_group_key(cx)], workspaces: vec![workspace], active_workspace_index: 0, sidebar: None, @@ -438,6 +445,20 @@ impl MultiWorkspace { window: &Window, cx: &mut Context, ) { + let project = workspace.read(cx).project().clone(); + cx.subscribe_in(&project, window, { + let workspace = workspace.downgrade(); + move |this, _project, event, _window, cx| match event { + project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { + if let Some(workspace) = workspace.upgrade() { + this.add_project_group_key(workspace.read(cx).project_group_key(cx)); + } + } + _ => {} + } + }) + .detach(); + cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| { if let WorkspaceEvent::Activate = event { this.activate(workspace.clone(), window, cx); @@ -446,6 +467,217 @@ impl MultiWorkspace { .detach(); } + pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) { + if self.project_group_keys.contains(&project_group_key) { + return; + } + self.project_group_keys.push(project_group_key); + } + + pub fn restore_project_group_keys(&mut self, keys: Vec) { + let mut restored = keys; + for existing_key in &self.project_group_keys { + if !restored.contains(existing_key) { + restored.push(existing_key.clone()); + } + } + self.project_group_keys = restored; + } + + pub fn project_group_keys(&self) -> impl Iterator { + self.project_group_keys.iter() + } + + /// Returns the project groups, ordered by most recently added. + pub fn project_groups( + &self, + cx: &App, + ) -> impl Iterator>)> { + let mut groups = self + .project_group_keys + .iter() + .rev() + .map(|key| (key.clone(), Vec::new())) + .collect::>(); + for workspace in &self.workspaces { + let key = workspace.read(cx).project_group_key(cx); + if let Some((_, workspaces)) = groups.iter_mut().find(|(k, _)| k == &key) { + workspaces.push(workspace.clone()); + } + } + groups.into_iter() + } + + pub fn workspaces_for_project_group( + &self, + project_group_key: &ProjectGroupKey, + cx: &App, + ) -> impl Iterator> { + self.workspaces + .iter() + .filter(move |ws| ws.read(cx).project_group_key(cx) == *project_group_key) + } + + pub fn remove_folder_from_project_group( + &mut self, + project_group_key: &ProjectGroupKey, + path: &Path, + cx: &mut Context, + ) { + let new_path_list = project_group_key.path_list().without_path(path); + if new_path_list.is_empty() { + return; + } + + let new_key = ProjectGroupKey::new(project_group_key.host(), new_path_list); + + let workspaces: Vec<_> = self + .workspaces_for_project_group(project_group_key, cx) + .cloned() + .collect(); + + self.add_project_group_key(new_key); + + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.remove_worktree_for_main_worktree_path(path, cx); + }); + } + + self.serialize(cx); + cx.notify(); + } + + pub fn prompt_to_add_folders_to_project_group( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let paths = self.workspace().update(cx, |workspace, cx| { + workspace.prompt_for_open_path( + PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: None, + }, + DirectoryLister::Project(workspace.project().clone()), + window, + cx, + ) + }); + + let key = key.clone(); + cx.spawn_in(window, async move |this, cx| { + if let Some(new_paths) = paths.await.ok().flatten() { + if !new_paths.is_empty() { + this.update(cx, |multi_workspace, cx| { + multi_workspace.add_folders_to_project_group(&key, new_paths, cx); + })?; + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn add_folders_to_project_group( + &mut self, + project_group_key: &ProjectGroupKey, + new_paths: Vec, + cx: &mut Context, + ) { + let mut all_paths: Vec = project_group_key.path_list().paths().to_vec(); + all_paths.extend(new_paths.iter().cloned()); + let new_path_list = PathList::new(&all_paths); + let new_key = ProjectGroupKey::new(project_group_key.host(), new_path_list); + + let workspaces: Vec<_> = self + .workspaces_for_project_group(project_group_key, cx) + .cloned() + .collect(); + + self.add_project_group_key(new_key); + + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + for path in &new_paths { + project + .update(cx, |project, cx| { + project.find_or_create_worktree(path, true, cx) + }) + .detach_and_log_err(cx); + } + } + + self.serialize(cx); + cx.notify(); + } + + pub fn remove_project_group( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + self.project_group_keys.retain(|k| k != key); + + let workspaces: Vec<_> = self + .workspaces_for_project_group(key, cx) + .cloned() + .collect(); + for workspace in workspaces { + self.remove(&workspace, window, cx); + } + + self.serialize(cx); + cx.notify(); + } + + /// Finds an existing workspace in this multi-workspace whose paths match, + /// or creates a new one (deserializing its saved state from the database). + /// Never searches other windows or matches workspaces with a superset of + /// the requested paths. + pub fn find_or_create_local_workspace( + &mut self, + path_list: PathList, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + if let Some(workspace) = self + .workspaces + .iter() + .find(|ws| ws.read(cx).project_group_key(cx).path_list() == &path_list) + .cloned() + { + self.activate(workspace.clone(), window, cx); + return Task::ready(Ok(workspace)); + } + + let paths = path_list.paths().to_vec(); + let app_state = self.workspace().read(cx).app_state().clone(); + let requesting_window = window.window_handle().downcast::(); + + cx.spawn(async move |_this, cx| { + let result = cx + .update(|cx| { + Workspace::new_local( + paths, + app_state, + requesting_window, + None, + None, + OpenMode::Activate, + cx, + ) + }) + .await?; + Ok(result.workspace) + }) + } + pub fn workspace(&self) -> &Entity { &self.workspaces[self.active_workspace_index] } @@ -492,48 +724,6 @@ impl MultiWorkspace { cx.notify(); } - /// Replaces the currently active workspace with a new one. If the - /// workspace is already in the list, this just switches to it. - pub fn replace( - &mut self, - workspace: Entity, - window: &Window, - cx: &mut Context, - ) { - if !self.multi_workspace_enabled(cx) { - self.set_single_workspace(workspace, cx); - return; - } - - if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { - let changed = self.active_workspace_index != index; - self.active_workspace_index = index; - if changed { - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); - self.serialize(cx); - } - cx.notify(); - return; - } - - let old_workspace = std::mem::replace( - &mut self.workspaces[self.active_workspace_index], - workspace.clone(), - ); - - let old_entity_id = old_workspace.entity_id(); - self.detach_workspace(&old_workspace, cx); - - Self::subscribe_to_workspace(&workspace, window, cx); - self.sync_sidebar_to_workspace(&workspace, cx); - - cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old_entity_id)); - cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); - self.serialize(cx); - cx.notify(); - } - fn set_single_workspace(&mut self, workspace: Entity, cx: &mut Context) { self.workspaces[0] = workspace; self.active_workspace_index = 0; @@ -553,12 +743,16 @@ impl MultiWorkspace { if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { index } else { + let project_group_key = workspace.read(cx).project().read(cx).project_group_key(cx); + Self::subscribe_to_workspace(&workspace, window, cx); self.sync_sidebar_to_workspace(&workspace, cx); let weak_self = cx.weak_entity(); workspace.update(cx, |workspace, cx| { workspace.set_multi_workspace(weak_self, cx); }); + + self.add_project_group_key(project_group_key); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); cx.notify(); @@ -625,8 +819,13 @@ impl MultiWorkspace { self._serialize_task = Some(cx.spawn(async move |this, cx| { let Some((window_id, state)) = this .read_with(cx, |this, cx| { - let state = crate::persistence::model::MultiWorkspaceState { + let state = MultiWorkspaceState { active_workspace_id: this.workspace().read(cx).database_id(), + project_group_keys: this + .project_group_keys() + .cloned() + .map(Into::into) + .collect::>(), sidebar_open: this.sidebar_open, sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)), }; @@ -877,7 +1076,7 @@ impl MultiWorkspace { return; } - let app_state: Arc = workspace.read(cx).app_state().clone(); + let app_state: Arc = workspace.read(cx).app_state().clone(); cx.defer(move |cx| { let options = (app_state.build_window_options)(None, cx); @@ -894,6 +1093,58 @@ impl MultiWorkspace { }); } + pub fn move_project_group_to_new_window( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let workspaces: Vec<_> = self + .workspaces_for_project_group(key, cx) + .cloned() + .collect(); + if workspaces.is_empty() { + return; + } + + self.project_group_keys.retain(|k| k != key); + + let mut removed = Vec::new(); + for workspace in &workspaces { + if self.remove(workspace, window, cx) { + removed.push(workspace.clone()); + } + } + + if removed.is_empty() { + return; + } + + let app_state = removed[0].read(cx).app_state().clone(); + + cx.defer(move |cx| { + let options = (app_state.build_window_options)(None, cx); + + let first = removed[0].clone(); + let rest = removed[1..].to_vec(); + + let Ok(new_window) = cx.open_window(options, |window, cx| { + cx.new(|cx| MultiWorkspace::new(first, window, cx)) + }) else { + return; + }; + + new_window + .update(cx, |mw, window, cx| { + for workspace in rest { + mw.activate(workspace, window, cx); + } + window.activate_window(); + }) + .log_err(); + }); + } + fn move_active_workspace_to_new_window( &mut self, _: &MoveWorkspaceToNewWindow, @@ -911,17 +1162,10 @@ impl MultiWorkspace { window: &mut Window, cx: &mut Context, ) -> Task>> { - let workspace = self.workspace().clone(); - - let needs_close_prompt = - open_mode == OpenMode::Replace || !self.multi_workspace_enabled(cx); - let open_mode = if self.multi_workspace_enabled(cx) { - open_mode + if self.multi_workspace_enabled(cx) { + self.find_or_create_local_workspace(PathList::new(&paths), window, cx) } else { - OpenMode::Replace - }; - - if needs_close_prompt { + let workspace = self.workspace().clone(); cx.spawn_in(window, async move |_this, cx| { let should_continue = workspace .update_in(cx, |workspace, window, cx| { @@ -938,10 +1182,6 @@ impl MultiWorkspace { Ok(workspace) } }) - } else { - workspace.update(cx, |workspace, cx| { - workspace.open_workspace_for_paths(open_mode, paths, window, cx) - }) } } } diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 50161121719ec7b2835fd11e389f24860e57d8f5..3083c23f6e3add91b0389a961567fc88e2043678 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -2,7 +2,8 @@ use super::*; use feature_flags::FeatureFlagAppExt; use fs::FakeFs; use gpui::TestAppContext; -use project::DisableAiSettings; +use project::{DisableAiSettings, ProjectGroupKey}; +use serde_json::json; use settings::SettingsStore; fn init_test(cx: &mut TestAppContext) { @@ -87,86 +88,232 @@ async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContex } #[gpui::test] -async fn test_replace(cx: &mut TestAppContext) { +async fn test_project_group_keys_initial(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); - let project_a = Project::test(fs.clone(), [], cx).await; - let project_b = Project::test(fs.clone(), [], cx).await; - let project_c = Project::test(fs.clone(), [], cx).await; - let project_d = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + let project = Project::test(fs, ["/root_a".as_ref()], cx).await; + + let expected_key = project.read_with(cx, |project, cx| project.project_group_key(cx)); let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + multi_workspace.read_with(cx, |mw, _cx| { + let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); + assert_eq!(keys.len(), 1, "should have exactly one key on creation"); + assert_eq!(*keys[0], expected_key); + }); +} - let workspace_a_id = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id()); +#[gpui::test] +async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await; - // Replace the only workspace (single-workspace case). - let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); - mw.replace(workspace.clone(), &*window, cx); - workspace + let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx)); + let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx)); + assert_ne!( + key_a, key_b, + "different roots should produce different keys" + ); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.project_group_keys().count(), 1); + }); + + // Adding a workspace with a different project root adds a new key. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); }); multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.workspaces().len(), 1); + let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); assert_eq!( - mw.workspaces()[0].entity_id(), - workspace_b.entity_id(), - "slot should now be project_b" - ); - assert_ne!( - mw.workspaces()[0].entity_id(), - workspace_a_id, - "project_a should be gone" + keys.len(), + 2, + "should have two keys after adding a second workspace" ); + assert_eq!(*keys[0], key_a); + assert_eq!(*keys[1], key_b); }); +} + +#[gpui::test] +async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + // A second project entity pointing at the same path produces the same key. + let project_a2 = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; - // Add project_c as a second workspace, then replace it with project_d. - let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_c.clone(), window, cx) + let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx)); + let key_a2 = project_a2.read_with(cx, |p, cx| p.project_group_key(cx)); + assert_eq!(key_a, key_a2, "same root path should produce the same key"); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_a2, window, cx); }); multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.workspaces().len(), 2); - assert_eq!(mw.active_workspace_index(), 1); + let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); + assert_eq!( + keys.len(), + 1, + "duplicate key should not be added when a workspace with the same root is inserted" + ); }); +} - let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx)); - mw.replace(workspace.clone(), &*window, cx); - workspace - }); +#[gpui::test] +async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; + let project = Project::test(fs, ["/root_a".as_ref()], cx).await; + + let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + // Add a second worktree to the same project. + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree("/root_b", true, cx) + }) + .await + .unwrap(); + worktree + .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); + assert_ne!( + initial_key, updated_key, + "key should change after adding a worktree" + ); multi_workspace.read_with(cx, |mw, _cx| { - assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces"); - assert_eq!(mw.active_workspace_index(), 1); + let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); assert_eq!( - mw.workspaces()[1].entity_id(), - workspace_d.entity_id(), - "active slot should now be project_d" - ); - assert_ne!( - mw.workspaces()[1].entity_id(), - workspace_c.entity_id(), - "project_c should be gone" + keys.len(), + 2, + "should have both the original and updated key" ); + assert_eq!(*keys[0], initial_key); + assert_eq!(*keys[1], updated_key); }); +} - // Replace with workspace_b which is already in the list — should just switch. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.replace(workspace_b.clone(), &*window, cx); +#[gpui::test] +async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; + let project = Project::test(fs, ["/root_a".as_ref(), "/root_b".as_ref()], cx).await; + + let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + // Remove one worktree. + let worktree_b_id = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .find(|wt| wt.read(cx).root_name().as_unix_str() == "root_b") + .unwrap() + .read(cx) + .id() + }); + project.update(cx, |project, cx| { + project.remove_worktree(worktree_b_id, cx); }); + cx.run_until_parked(); + + let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); + assert_ne!( + initial_key, updated_key, + "key should change after removing a worktree" + ); multi_workspace.read_with(cx, |mw, _cx| { + let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); assert_eq!( - mw.workspaces().len(), + keys.len(), 2, - "no workspace should be added or removed" + "should accumulate both the original and post-removal key" ); + assert_eq!(*keys[0], initial_key); + assert_eq!(*keys[1], updated_key); + }); +} + +#[gpui::test] +async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + fs.insert_tree("/root_b", json!({ "file.txt": "" })).await; + fs.insert_tree("/root_c", json!({ "file.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await; + + let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx)); + let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx)); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.project_group_keys().count(), 2); + }); + + // Now add a worktree to project_a. This should produce a third key. + let (worktree, _) = project_a + .update(cx, |project, cx| { + project.find_or_create_worktree("/root_c", true, cx) + }) + .await + .unwrap(); + worktree + .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + let key_a_updated = project_a.read_with(cx, |p, cx| p.project_group_key(cx)); + assert_ne!(key_a, key_a_updated); + + multi_workspace.read_with(cx, |mw, _cx| { + let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); assert_eq!( - mw.active_workspace_index(), - 0, - "should have switched to workspace_b" + keys.len(), + 3, + "should have key_a, key_b, and the updated key_a with root_c" ); + assert_eq!(*keys[0], key_a); + assert_eq!(*keys[1], key_b); + assert_eq!(*keys[2], key_a_updated); }); } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index deb7e1efef37acff992d8f5be5825741e887b979..92f0781f82234ce79d47db08785b6592fb53f566 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2,6 +2,7 @@ use crate::{ CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace, WorkspaceItemBuilder, ZoomIn, ZoomOut, + focus_follows_mouse::FocusFollowsMouse as _, invalid_item_view::InvalidItemView, item::{ ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings, @@ -11,7 +12,7 @@ use crate::{ move_item, notifications::NotifyResultExt, toolbar::Toolbar, - workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings}, + workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings}, }; use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; @@ -443,6 +444,7 @@ pub struct Pane { pinned_tab_count: usize, diagnostics: HashMap, zoom_out_on_close: bool, + focus_follows_mouse: FocusFollowsMouse, diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, @@ -615,6 +617,7 @@ impl Pane { pinned_tab_count: 0, diagnostics: Default::default(), zoom_out_on_close: true, + focus_follows_mouse: WorkspaceSettings::get_global(cx).focus_follows_mouse, diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), welcome_page: None, @@ -782,7 +785,6 @@ impl Pane { fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { let tab_bar_settings = TabBarSettings::get_global(cx); - let new_max_tabs = WorkspaceSettings::get_global(cx).max_tabs; if let Some(display_nav_history_buttons) = self.display_nav_history_buttons.as_mut() { *display_nav_history_buttons = tab_bar_settings.show_nav_history_buttons; @@ -795,6 +797,12 @@ impl Pane { self.nav_history.0.lock().preview_item_id = None; } + let workspace_settings = WorkspaceSettings::get_global(cx); + + self.focus_follows_mouse = workspace_settings.focus_follows_mouse; + + let new_max_tabs = workspace_settings.max_tabs; + if self.use_max_tabs && new_max_tabs != self.max_tabs { self.max_tabs = new_max_tabs; self.close_items_on_settings_change(window, cx); @@ -4460,6 +4468,7 @@ impl Render for Pane { placeholder.child(self.welcome_page.clone().unwrap()) } } + .focus_follows_mouse(self.focus_follows_mouse, cx) }) .child( // drag target diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index d38602ea768e8edc4f3de1ec439e67f0ee432a63..644ff0282df216e79d6be24918d29b802e50a0e8 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -337,15 +337,20 @@ pub fn read_serialized_multi_workspaces( window_groups .into_iter() - .map(|group| { + .filter_map(|group| { let window_id = group.first().and_then(|sw| sw.window_id); let state = window_id .map(|wid| read_multi_workspace_state(wid, cx)) .unwrap_or_default(); - model::SerializedMultiWorkspace { - workspaces: group, + let active_workspace = state + .active_workspace_id + .and_then(|id| group.iter().position(|ws| ws.workspace_id == id)) + .or(Some(0)) + .and_then(|index| group.into_iter().nth(index))?; + Some(model::SerializedMultiWorkspace { + active_workspace, state, - } + }) }) .collect() } @@ -2488,11 +2493,20 @@ pub fn delete_unloaded_items( #[cfg(test)] mod tests { use super::*; - use crate::persistence::model::{ - SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, SessionWorkspace, + use crate::{ + multi_workspace::MultiWorkspace, + persistence::{ + model::{ + SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, + SessionWorkspace, + }, + read_multi_workspace_state, + }, }; - use gpui; + use feature_flags::FeatureFlagAppExt; + use gpui::AppContext as _; use pretty_assertions::assert_eq; + use project::{Project, ProjectGroupKey}; use remote::SshConnectionOptions; use serde_json::json; use std::{thread, time::Duration}; @@ -2507,12 +2521,6 @@ mod tests { #[gpui::test] async fn test_multi_workspace_serializes_on_add_and_remove(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use crate::persistence::read_multi_workspace_state; - use feature_flags::FeatureFlagAppExt; - use gpui::AppContext as _; - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -3993,6 +4001,7 @@ mod tests { window_10, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(2)), + project_group_keys: vec![], sidebar_open: true, sidebar_state: None, }, @@ -4004,6 +4013,7 @@ mod tests { window_20, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(3)), + project_group_keys: vec![], sidebar_open: false, sidebar_state: None, }, @@ -4040,35 +4050,30 @@ mod tests { let results = cx.update(|cx| read_serialized_multi_workspaces(session_workspaces, cx)); - // Should produce 3 groups: window 10, window 20, and the orphan. + // Should produce 3 results: window 10, window 20, and the orphan. assert_eq!(results.len(), 3); - // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open. + // Window 10: active_workspace_id = 2 picks workspace 2 (paths /b), sidebar open. let group_10 = &results[0]; - assert_eq!(group_10.workspaces.len(), 2); + assert_eq!(group_10.active_workspace.workspace_id, WorkspaceId(2)); assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2))); assert_eq!(group_10.state.sidebar_open, true); - // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed. + // Window 20: active_workspace_id = 3 picks workspace 3 (paths /c), sidebar closed. let group_20 = &results[1]; - assert_eq!(group_20.workspaces.len(), 1); + assert_eq!(group_20.active_workspace.workspace_id, WorkspaceId(3)); assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3))); assert_eq!(group_20.state.sidebar_open, false); - // Orphan group: no window_id, so state is default. + // Orphan: no active_workspace_id, falls back to first workspace (id 4). let group_none = &results[2]; - assert_eq!(group_none.workspaces.len(), 1); + assert_eq!(group_none.active_workspace.workspace_id, WorkspaceId(4)); assert_eq!(group_none.state.active_workspace_id, None); assert_eq!(group_none.state.sidebar_open, false); } #[gpui::test] async fn test_flush_serialization_completes_before_quit(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use feature_flags::FeatureFlagAppExt; - - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4114,12 +4119,6 @@ mod tests { #[gpui::test] async fn test_create_workspace_serialization(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use crate::persistence::read_multi_workspace_state; - use feature_flags::FeatureFlagAppExt; - - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4177,11 +4176,6 @@ mod tests { #[gpui::test] async fn test_remove_workspace_clears_session_binding(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use feature_flags::FeatureFlagAppExt; - use gpui::AppContext as _; - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4269,11 +4263,6 @@ mod tests { #[gpui::test] async fn test_remove_workspace_not_restored_as_zombie(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use feature_flags::FeatureFlagAppExt; - use gpui::AppContext as _; - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4376,11 +4365,6 @@ mod tests { #[gpui::test] async fn test_pending_removal_tasks_drained_on_flush(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use feature_flags::FeatureFlagAppExt; - use gpui::AppContext as _; - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4482,10 +4466,6 @@ mod tests { #[gpui::test] async fn test_create_workspace_bounds_observer_uses_fresh_id(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use feature_flags::FeatureFlagAppExt; - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4543,10 +4523,6 @@ mod tests { #[gpui::test] async fn test_flush_serialization_writes_bounds(cx: &mut gpui::TestAppContext) { - use crate::multi_workspace::MultiWorkspace; - use feature_flags::FeatureFlagAppExt; - use project::Project; - crate::tests::init_test(cx); cx.update(|cx| { @@ -4702,4 +4678,219 @@ mod tests { assert_eq!(result[2].2.paths(), &[PathBuf::from("/plain-project")]); assert_eq!(result[2].0, WorkspaceId(4)); } + + #[gpui::test] + async fn test_restore_window_with_linked_worktree_and_multiple_project_groups( + cx: &mut gpui::TestAppContext, + ) { + crate::tests::init_test(cx); + + cx.update(|cx| { + cx.set_staff(true); + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + + // Main git repo at /repo + fs.insert_tree( + "/repo", + json!({ + ".git": { + "HEAD": "ref: refs/heads/main", + "worktrees": { + "feature": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature" + } + } + }, + "src": { "main.rs": "" } + }), + ) + .await; + + // Linked worktree checkout pointing back to /repo + fs.insert_tree( + "/worktree-feature", + json!({ + ".git": "gitdir: /repo/.git/worktrees/feature", + "src": { "lib.rs": "" } + }), + ) + .await; + + // --- Phase 1: Set up the original multi-workspace window --- + + let project_1 = Project::test(fs.clone(), ["/repo".as_ref()], cx).await; + let project_1_linked_worktree = + Project::test(fs.clone(), ["/worktree-feature".as_ref()], cx).await; + + // Wait for git discovery to finish. + cx.run_until_parked(); + + // Create a second, unrelated project so we have two distinct project groups. + fs.insert_tree( + "/other-project", + json!({ + ".git": { "HEAD": "ref: refs/heads/main" }, + "readme.md": "" + }), + ) + .await; + let project_2 = Project::test(fs.clone(), ["/other-project".as_ref()], cx).await; + cx.run_until_parked(); + + // Create the MultiWorkspace with project_2, then add the main repo + // and its linked worktree. The linked worktree is added last and + // becomes the active workspace. + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_2.clone(), window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_1.clone(), window, cx); + }); + + let workspace_worktree = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_1_linked_worktree.clone(), window, cx) + }); + + // Assign database IDs and set up session bindings so serialization + // writes real rows. + multi_workspace.update_in(cx, |mw, _, cx| { + for workspace in mw.workspaces() { + workspace.update(cx, |ws, _cx| { + ws.set_random_database_id(); + }); + } + }); + + // Flush serialization for each individual workspace (writes to SQLite) + // and for the MultiWorkspace (writes to KVP). + let tasks = multi_workspace.update_in(cx, |mw, window, cx| { + let session_id = mw.workspace().read(cx).session_id(); + let window_id_u64 = window.window_handle().window_id().as_u64(); + + let mut tasks: Vec> = Vec::new(); + for workspace in mw.workspaces() { + tasks.push(workspace.update(cx, |ws, cx| ws.flush_serialization(window, cx))); + if let Some(db_id) = workspace.read(cx).database_id() { + let db = WorkspaceDb::global(cx); + let session_id = session_id.clone(); + tasks.push(cx.background_spawn(async move { + db.set_session_binding(db_id, session_id, Some(window_id_u64)) + .await + .log_err(); + })); + } + } + mw.serialize(cx); + tasks + }); + cx.run_until_parked(); + for task in tasks { + task.await; + } + cx.run_until_parked(); + + let active_db_id = workspace_worktree.read_with(cx, |ws, _| ws.database_id()); + assert!( + active_db_id.is_some(), + "Active workspace should have a database ID" + ); + + // --- Phase 2: Read back and verify the serialized state --- + + let session_id = multi_workspace + .read_with(cx, |mw, cx| mw.workspace().read(cx).session_id()) + .unwrap(); + let db = cx.update(|_, cx| WorkspaceDb::global(cx)); + let session_workspaces = db + .last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .expect("should load session workspaces"); + assert!( + !session_workspaces.is_empty(), + "Should have at least one session workspace" + ); + + let multi_workspaces = + cx.update(|_, cx| read_serialized_multi_workspaces(session_workspaces, cx)); + assert_eq!( + multi_workspaces.len(), + 1, + "All workspaces share one window, so there should be exactly one multi-workspace" + ); + + let serialized = &multi_workspaces[0]; + assert_eq!( + serialized.active_workspace.workspace_id, + active_db_id.unwrap(), + ); + assert_eq!(serialized.state.project_group_keys.len(), 2,); + + // Verify the serialized project group keys round-trip back to the + // originals. + let restored_keys: Vec = serialized + .state + .project_group_keys + .iter() + .cloned() + .map(Into::into) + .collect(); + let expected_keys = vec![ + ProjectGroupKey::new(None, PathList::new(&["/other-project"])), + ProjectGroupKey::new(None, PathList::new(&["/repo"])), + ]; + assert_eq!( + restored_keys, expected_keys, + "Deserialized project group keys should match the originals" + ); + + // --- Phase 3: Restore the window and verify the result --- + + let app_state = + multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).app_state().clone()); + + let serialized_mw = multi_workspaces.into_iter().next().unwrap(); + let restored_handle: gpui::WindowHandle = cx + .update(|_, cx| { + cx.spawn(async move |mut cx| { + crate::restore_multiworkspace(serialized_mw, app_state, &mut cx).await + }) + }) + .await + .expect("restore_multiworkspace should succeed"); + + cx.run_until_parked(); + + // The restored window should have the same project group keys. + let restored_keys: Vec = restored_handle + .read_with(cx, |mw: &MultiWorkspace, _cx| { + mw.project_group_keys().cloned().collect() + }) + .unwrap(); + assert_eq!( + restored_keys, expected_keys, + "Restored window should have the same project group keys as the original" + ); + + // The active workspace in the restored window should have the linked + // worktree paths. + let active_paths: Vec = restored_handle + .read_with(cx, |mw: &MultiWorkspace, cx| { + mw.workspace() + .read(cx) + .root_paths(cx) + .into_iter() + .map(|p: Arc| p.to_path_buf()) + .collect() + }) + .unwrap(); + assert_eq!( + active_paths, + vec![PathBuf::from("/worktree-feature")], + "The restored active workspace should be the linked worktree project" + ); + } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 6b55d09ebbc2375f8cce3f2b81bc4f1aa9620e76..b50d82fff0b05c3511967dd65a9060e38ca4ca26 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -13,7 +13,7 @@ use db::sqlez::{ use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId}; use language::{Toolchain, ToolchainScope}; -use project::{Project, debugger::breakpoint_store::SourceBreakpoint}; +use project::{Project, ProjectGroupKey, debugger::breakpoint_store::SourceBreakpoint}; use remote::RemoteConnectionOptions; use serde::{Deserialize, Serialize}; use std::{ @@ -21,7 +21,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::ResultExt; +use util::{ResultExt, path_list::SerializedPathList}; use uuid::Uuid; #[derive( @@ -36,7 +36,7 @@ pub(crate) enum RemoteConnectionKind { Docker, } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)] pub enum SerializedWorkspaceLocation { Local, Remote(RemoteConnectionOptions), @@ -59,21 +59,51 @@ pub struct SessionWorkspace { pub window_id: Option, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SerializedProjectGroupKey { + pub path_list: SerializedPathList, + pub(crate) location: SerializedWorkspaceLocation, +} + +impl From for SerializedProjectGroupKey { + fn from(value: ProjectGroupKey) -> Self { + SerializedProjectGroupKey { + path_list: value.path_list().serialize(), + location: match value.host() { + Some(host) => SerializedWorkspaceLocation::Remote(host), + None => SerializedWorkspaceLocation::Local, + }, + } + } +} + +impl From for ProjectGroupKey { + fn from(value: SerializedProjectGroupKey) -> Self { + let path_list = PathList::deserialize(&value.path_list); + let host = match value.location { + SerializedWorkspaceLocation::Local => None, + SerializedWorkspaceLocation::Remote(opts) => Some(opts), + }; + ProjectGroupKey::new(host, path_list) + } +} + /// Per-window state for a MultiWorkspace, persisted to KVP. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct MultiWorkspaceState { pub active_workspace_id: Option, pub sidebar_open: bool, + pub project_group_keys: Vec, #[serde(default)] pub sidebar_state: Option, } /// The serialized state of a single MultiWorkspace window from a previous session: -/// all workspaces that shared the window, which one was active, and whether the -/// sidebar was open. +/// the active workspace to restore plus window-level state (project group keys, +/// sidebar). #[derive(Debug, Clone)] pub struct SerializedMultiWorkspace { - pub workspaces: Vec, + pub active_workspace: SessionWorkspace, pub state: MultiWorkspaceState, } diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 93d809d7a522d11e4b4bd78e71899b89aa4d0508..f0932a7d7b3e7880c27b40c28890f063f4de731e 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -55,6 +55,7 @@ pub struct SearchOptions { /// Specifies whether the supports search & replace. pub replacement: bool, pub selection: bool, + pub select_all: bool, pub find_in_results: bool, } @@ -78,6 +79,7 @@ pub trait SearchableItem: Item + EventEmitter { regex: true, replacement: true, selection: true, + select_all: true, find_in_results: false, } } diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index efd9b75a6802f888f43654e21006f202cc36c5a4..dceca3e85f4308952563e689c608c92e9f77144f 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -326,7 +326,7 @@ impl WelcomePage { self.workspace .update(cx, |workspace, cx| { workspace - .open_workspace_for_paths(OpenMode::Replace, paths, window, cx) + .open_workspace_for_paths(OpenMode::Activate, paths, window, cx) .detach_and_log_err(cx); }) .log_err(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aa692ab39a6084126c9b15b07856549364b13842..c726d0a421928979200a088125d3ddd172530ff9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -19,6 +19,7 @@ mod security_modal; pub mod shared_screen; use db::smol::future::yield_now; pub use shared_screen::SharedScreen; +pub mod focus_follows_mouse; mod status_bar; pub mod tasks; mod theme_preview; @@ -83,15 +84,15 @@ use persistence::{SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ WorkspaceDb, delete_unloaded_items, model::{ - DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, - SessionWorkspace, + DockStructure, ItemId, MultiWorkspaceState, SerializedMultiWorkspace, + SerializedWorkspaceLocation, SessionWorkspace, }, read_serialized_multi_workspaces, resolve_worktree_workspaces, }; use postage::stream::Stream; use project::{ - DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, - WorktreeSettings, + DirectoryLister, Project, ProjectEntryId, ProjectGroupKey, ProjectPath, ResolvedPath, Worktree, + WorktreeId, WorktreeSettings, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, project_settings::ProjectSettings, toolchain_store::ToolchainStoreEvent, @@ -147,8 +148,8 @@ use util::{ }; use uuid::Uuid; pub use workspace_settings::{ - AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings, - WorkspaceSettings, + AutosaveSetting, BottomDockLayout, FocusFollowsMouse, RestoreOnStartupBehavior, + StatusBarSettings, TabBarSettings, WorkspaceSettings, }; use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; @@ -655,13 +656,25 @@ impl From for i64 { } } -fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, cx: &mut App) { +fn prompt_and_open_paths( + app_state: Arc, + options: PathPromptOptions, + create_new_window: bool, + cx: &mut App, +) { if let Some(workspace_window) = local_workspace_windows(cx).into_iter().next() { workspace_window .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { - prompt_for_open_path_and_open(workspace, app_state, options, true, window, cx); + prompt_for_open_path_and_open( + workspace, + app_state, + options, + create_new_window, + window, + cx, + ); }); }) .ok(); @@ -672,7 +685,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c None, None, None, - OpenMode::Replace, + OpenMode::Activate, cx, ); cx.spawn(async move |cx| { @@ -681,7 +694,14 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c window.activate_window(); let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { - prompt_for_open_path_and_open(workspace, app_state, options, true, window, cx); + prompt_for_open_path_and_open( + workspace, + app_state, + options, + create_new_window, + window, + cx, + ); }); })?; anyhow::Ok(()) @@ -713,7 +733,7 @@ pub fn prompt_for_open_path_and_open( if let Some(handle) = multi_workspace_handle { if let Some(task) = handle .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, OpenMode::Replace, window, cx) + multi_workspace.open_project(paths, OpenMode::Activate, window, cx) }) .log_err() { @@ -742,7 +762,7 @@ pub fn init(app_state: Arc, cx: &mut App) { cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) .on_action(|_: &Reload, cx| reload(cx)) - .on_action(|_: &Open, cx: &mut App| { + .on_action(|action: &Open, cx: &mut App| { let app_state = AppState::global(cx); prompt_and_open_paths( app_state, @@ -752,6 +772,7 @@ pub fn init(app_state: Arc, cx: &mut App) { multiple: true, prompt: None, }, + action.create_new_window, cx, ); }) @@ -766,6 +787,7 @@ pub fn init(app_state: Arc, cx: &mut App) { multiple: true, prompt: None, }, + true, cx, ); }); @@ -1344,6 +1366,8 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + open_in_dev_container: bool, + _dev_container_task: Option>>, _panels_task: Option>>, sidebar_focus_handle: Option, multi_workspace: Option>, @@ -1378,8 +1402,6 @@ pub enum OpenMode { /// Add to the window's multi workspace and activate it. #[default] Activate, - /// Replace the currently active workspace, and any of it's linked workspaces - Replace, } impl Workspace { @@ -1778,6 +1800,8 @@ impl Workspace { removing: false, sidebar_focus_handle: None, multi_workspace, + open_in_dev_container: false, + _dev_container_task: None, } } @@ -1917,9 +1941,6 @@ impl Workspace { workspace }); match open_mode { - OpenMode::Replace => { - multi_workspace.replace(workspace.clone(), &*window, cx); - } OpenMode::Activate => { multi_workspace.activate(workspace.clone(), window, cx); } @@ -2052,6 +2073,10 @@ impl Workspace { }) } + pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey { + self.project.read(cx).project_group_key(cx) + } + pub fn weak_handle(&self) -> WeakEntity { self.weak_self.clone() } @@ -2800,6 +2825,18 @@ impl Workspace { self.debugger_provider = Some(Arc::new(provider)); } + pub fn set_open_in_dev_container(&mut self, value: bool) { + self.open_in_dev_container = value; + } + + pub fn open_in_dev_container(&self) -> bool { + self.open_in_dev_container + } + + pub fn set_dev_container_task(&mut self, task: Task>) { + self._dev_container_task = Some(task); + } + pub fn debugger_provider(&self) -> Option> { self.debugger_provider.clone() } @@ -3026,7 +3063,6 @@ impl Workspace { self.project.read(cx).visible_worktrees(cx) } - #[cfg(any(test, feature = "test-support"))] pub fn worktree_scans_complete(&self, cx: &App) -> impl Future + 'static + use<> { let futures = self .worktrees(cx) @@ -3394,7 +3430,7 @@ impl Workspace { let workspace_is_empty = !is_remote && !has_worktree && !has_dirty_items; if workspace_is_empty { - open_mode = OpenMode::Replace; + open_mode = OpenMode::Activate; } let app_state = self.app_state.clone(); @@ -7678,11 +7714,6 @@ impl GlobalAnyActiveCall { } } -pub fn merge_conflict_notification_id() -> NotificationId { - struct MergeConflictNotification; - NotificationId::unique::() -} - /// Workspace-local view of a remote participant's location. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParticipantLocation { @@ -8606,30 +8637,32 @@ pub async fn last_session_workspace_locations( .log_err() } -pub struct MultiWorkspaceRestoreResult { - pub window_handle: WindowHandle, - pub errors: Vec, -} - pub async fn restore_multiworkspace( multi_workspace: SerializedMultiWorkspace, app_state: Arc, cx: &mut AsyncApp, -) -> anyhow::Result { - let SerializedMultiWorkspace { workspaces, state } = multi_workspace; - let mut group_iter = workspaces.into_iter(); - let first = group_iter - .next() - .context("window group must not be empty")?; - - let window_handle = if first.paths.is_empty() { - cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx)) - .await? +) -> anyhow::Result> { + let SerializedMultiWorkspace { + active_workspace, + state, + } = multi_workspace; + let MultiWorkspaceState { + sidebar_open, + project_group_keys, + sidebar_state, + .. + } = state; + + let window_handle = if active_workspace.paths.is_empty() { + cx.update(|cx| { + open_workspace_by_id(active_workspace.workspace_id, app_state.clone(), None, cx) + }) + .await? } else { let OpenResult { window, .. } = cx .update(|cx| { Workspace::new_local( - first.paths.paths().to_vec(), + active_workspace.paths.paths().to_vec(), app_state.clone(), None, None, @@ -8642,65 +8675,17 @@ pub async fn restore_multiworkspace( window }; - let mut errors = Vec::new(); - - for session_workspace in group_iter { - let error = if session_workspace.paths.is_empty() { - cx.update(|cx| { - open_workspace_by_id( - session_workspace.workspace_id, - app_state.clone(), - Some(window_handle), - cx, - ) - }) - .await - .err() - } else { - cx.update(|cx| { - Workspace::new_local( - session_workspace.paths.paths().to_vec(), - app_state.clone(), - Some(window_handle), - None, - None, - OpenMode::Add, - cx, - ) - }) - .await - .err() - }; - - if let Some(error) = error { - errors.push(error); - } - } - - if let Some(target_id) = state.active_workspace_id { - window_handle - .update(cx, |multi_workspace, window, cx| { - let target_index = multi_workspace - .workspaces() - .iter() - .position(|ws| ws.read(cx).database_id() == Some(target_id)); - let index = target_index.unwrap_or(0); - if let Some(workspace) = multi_workspace.workspaces().get(index).cloned() { - multi_workspace.activate(workspace, window, cx); - } - }) - .ok(); - } else { + if !project_group_keys.is_empty() { + let restored_keys: Vec = + project_group_keys.into_iter().map(Into::into).collect(); window_handle - .update(cx, |multi_workspace, window, cx| { - if let Some(workspace) = multi_workspace.workspaces().first().cloned() { - multi_workspace.activate(workspace, window, cx); - } + .update(cx, |multi_workspace, _window, _cx| { + multi_workspace.restore_project_group_keys(restored_keys); }) .ok(); } - if state.sidebar_open { + if sidebar_open { window_handle .update(cx, |multi_workspace, _, cx| { multi_workspace.open_sidebar(cx); @@ -8708,8 +8693,7 @@ pub async fn restore_multiworkspace( .ok(); } - if let Some(sidebar_state) = &state.sidebar_state { - let sidebar_state = sidebar_state.clone(); + if let Some(sidebar_state) = sidebar_state { window_handle .update(cx, |multi_workspace, window, cx| { if let Some(sidebar) = multi_workspace.sidebar() { @@ -8726,10 +8710,7 @@ pub async fn restore_multiworkspace( }) .ok(); - Ok(MultiWorkspaceRestoreResult { - window_handle, - errors, - }) + Ok(window_handle) } actions!( @@ -9214,6 +9195,7 @@ pub struct OpenOptions { pub requesting_window: Option>, pub open_mode: OpenMode, pub env: Option>, + pub open_in_dev_container: bool, } /// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`], @@ -9393,12 +9375,17 @@ pub fn open_paths( } } + let open_in_dev_container = open_options.open_in_dev_container; + let result = if let Some((existing, target_workspace)) = existing { let open_task = existing .update(cx, |multi_workspace, window, cx| { window.activate_window(); multi_workspace.activate(target_workspace.clone(), window, cx); target_workspace.update(cx, |workspace, cx| { + if open_in_dev_container { + workspace.set_open_in_dev_container(true); + } workspace.open_paths( abs_paths, OpenOptions { @@ -9426,6 +9413,13 @@ pub fn open_paths( Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task }) } else { + let init = if open_in_dev_container { + Some(Box::new(|workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context| { + workspace.set_open_in_dev_container(true); + }) as Box) + Send>) + } else { + None + }; let result = cx .update(move |cx| { Workspace::new_local( @@ -9433,7 +9427,7 @@ pub fn open_paths( app_state.clone(), open_options.requesting_window, open_options.env, - None, + init, open_options.open_mode, cx, ) diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index d78b233229800b571ccc37f87719d09125f1c4c3..ee0e80336d744cadaecdf0201525deddb8d5eec9 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroUsize; +use std::{num::NonZeroUsize, time::Duration}; use crate::DockPosition; use collections::HashMap; @@ -35,6 +35,13 @@ pub struct WorkspaceSettings { pub use_system_window_tabs: bool, pub zoomed_padding: bool, pub window_decorations: settings::WindowDecorations, + pub focus_follows_mouse: FocusFollowsMouse, +} + +#[derive(Copy, Clone, Deserialize)] +pub struct FocusFollowsMouse { + pub enabled: bool, + pub debounce: Duration, } #[derive(Copy, Clone, PartialEq, Debug, Default)] @@ -113,6 +120,20 @@ impl Settings for WorkspaceSettings { use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), window_decorations: workspace.window_decorations.unwrap(), + focus_follows_mouse: FocusFollowsMouse { + enabled: workspace + .focus_follows_mouse + .unwrap() + .enabled + .unwrap_or(false), + debounce: Duration::from_millis( + workspace + .focus_follows_mouse + .unwrap() + .debounce_ms + .unwrap_or(250), + ), + }, } } } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b08f9aaee016d7047b06bf9ac4a4a1ce2b2d1ad8..864858073db70c984e61dbf43bf98be44f6c1c58 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -176,6 +176,7 @@ pub struct Snapshot { root_char_bag: CharBag, entries_by_path: SumTree, entries_by_id: SumTree, + root_repo_common_dir: Option>, always_included_entries: Vec>, /// A number that increases every time the worktree begins scanning @@ -368,6 +369,7 @@ struct UpdateObservationState { pub enum Event { UpdatedEntries(UpdatedEntriesSet), UpdatedGitRepositories(UpdatedGitRepositoriesSet), + UpdatedRootRepoCommonDir, DeletedEntry(ProjectEntryId), /// The worktree root itself has been deleted (for single-file worktrees) Deleted, @@ -407,6 +409,10 @@ impl Worktree { None }; + let root_repo_common_dir = discover_root_repo_common_dir(&abs_path, fs.as_ref()) + .await + .map(SanitizedPath::from_arc); + Ok(cx.new(move |cx: &mut Context| { let mut snapshot = LocalSnapshot { ignores_by_parent_abs_path: Default::default(), @@ -426,6 +432,7 @@ impl Worktree { ), root_file_handle, }; + snapshot.root_repo_common_dir = root_repo_common_dir; let worktree_id = snapshot.id(); let settings_location = Some(SettingsLocation { @@ -564,6 +571,7 @@ impl Worktree { this.update(cx, |this, cx| { let mut entries_changed = false; let this = this.as_remote_mut().unwrap(); + let old_root_repo_common_dir = this.snapshot.root_repo_common_dir.clone(); { let mut lock = this.background_snapshot.lock(); this.snapshot = lock.0.clone(); @@ -579,6 +587,9 @@ impl Worktree { if entries_changed { cx.emit(Event::UpdatedEntries(Arc::default())); } + if this.snapshot.root_repo_common_dir != old_root_repo_common_dir { + cx.emit(Event::UpdatedRootRepoCommonDir); + } cx.notify(); while let Some((scan_id, _)) = this.snapshot_subscriptions.front() { if this.observed_snapshot(*scan_id) { @@ -1183,6 +1194,13 @@ impl LocalWorktree { cx: &mut Context, ) { let repo_changes = self.changed_repos(&self.snapshot, &mut new_snapshot); + + new_snapshot.root_repo_common_dir = new_snapshot + .local_repo_for_work_directory_path(RelPath::empty()) + .map(|repo| SanitizedPath::from_arc(repo.common_dir_abs_path.clone())); + + let root_repo_common_dir_changed = + self.snapshot.root_repo_common_dir != new_snapshot.root_repo_common_dir; self.snapshot = new_snapshot; if let Some(share) = self.update_observer.as_mut() { @@ -1198,6 +1216,9 @@ impl LocalWorktree { if !repo_changes.is_empty() { cx.emit(Event::UpdatedGitRepositories(repo_changes)); } + if root_repo_common_dir_changed { + cx.emit(Event::UpdatedRootRepoCommonDir); + } while let Some((scan_id, _)) = self.snapshot_subscriptions.front() { if self.snapshot.completed_scan_id >= *scan_id { @@ -2216,6 +2237,7 @@ impl Snapshot { always_included_entries: Default::default(), entries_by_path: Default::default(), entries_by_id: Default::default(), + root_repo_common_dir: None, scan_id: 1, completed_scan_id: 0, } @@ -2241,6 +2263,12 @@ impl Snapshot { SanitizedPath::cast_arc_ref(&self.abs_path) } + pub fn root_repo_common_dir(&self) -> Option<&Arc> { + self.root_repo_common_dir + .as_ref() + .map(SanitizedPath::cast_arc_ref) + } + fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree { let mut updated_entries = self .entries_by_path @@ -2254,6 +2282,9 @@ impl Snapshot { worktree_id, abs_path: self.abs_path().to_string_lossy().into_owned(), root_name: self.root_name().to_proto(), + root_repo_common_dir: self + .root_repo_common_dir() + .map(|p| p.to_string_lossy().into_owned()), updated_entries, removed_entries: Vec::new(), scan_id: self.scan_id as u64, @@ -2399,6 +2430,10 @@ impl Snapshot { self.entries_by_path.edit(entries_by_path_edits, ()); self.entries_by_id.edit(entries_by_id_edits, ()); + self.root_repo_common_dir = update + .root_repo_common_dir + .map(|p| SanitizedPath::new_arc(Path::new(&p))); + self.scan_id = update.scan_id as usize; if update.is_last_update { self.completed_scan_id = update.scan_id as usize; @@ -2627,6 +2662,9 @@ impl LocalSnapshot { worktree_id, abs_path: self.abs_path().to_string_lossy().into_owned(), root_name: self.root_name().to_proto(), + root_repo_common_dir: self + .root_repo_common_dir() + .map(|p| p.to_string_lossy().into_owned()), updated_entries, removed_entries, scan_id: self.scan_id as u64, @@ -6071,6 +6109,16 @@ fn parse_gitfile(content: &str) -> anyhow::Result<&Path> { Ok(Path::new(path.trim())) } +async fn discover_root_repo_common_dir(root_abs_path: &Path, fs: &dyn Fs) -> Option> { + let root_dot_git = root_abs_path.join(DOT_GIT); + if !fs.metadata(&root_dot_git).await.is_ok_and(|m| m.is_some()) { + return None; + } + let dot_git_path: Arc = root_dot_git.into(); + let (_, common_dir) = discover_git_paths(&dot_git_path, fs).await; + Some(common_dir) +} + async fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc, Arc) { let mut repository_dir_abs_path = dot_git_abs_path.clone(); let mut common_dir_abs_path = dot_git_abs_path.clone(); diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index cd7dd1c9056a7d501bec2bcd7b07d596f689a908..b8d1994b1dc3f8ddbd482dd0863e3441ab7adc64 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -2736,6 +2736,97 @@ fn check_worktree_entries( } } +#[gpui::test] +async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + use git::repository::Worktree as GitWorktree; + + let fs = FakeFs::new(executor); + + // Set up a main repo and a linked worktree pointing back to it. + fs.insert_tree( + path!("/main_repo"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/main_repo/.git")), + false, + GitWorktree { + path: PathBuf::from(path!("/linked_worktree")), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + }, + ) + .await; + fs.write( + path!("/linked_worktree/file.txt").as_ref(), + "content".as_bytes(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + path!("/linked_worktree").as_ref(), + true, + fs.clone(), + Arc::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + // For a linked worktree, root_repo_common_dir should point to the + // main repo's .git, not the worktree-specific git directory. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()), + Some(Path::new(path!("/main_repo/.git"))), + ); + }); + + let event_count: Rc> = Rc::new(Cell::new(0)); + tree.update(cx, { + let event_count = event_count.clone(); + |_, cx| { + cx.subscribe(&cx.entity(), move |_, _, event, _| { + if matches!(event, Event::UpdatedRootRepoCommonDir) { + event_count.set(event_count.get() + 1); + } + }) + .detach(); + } + }); + + // Remove .git — root_repo_common_dir should become None. + fs.remove_file( + &PathBuf::from(path!("/linked_worktree/.git")), + Default::default(), + ) + .await + .unwrap(); + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _| { + assert_eq!(tree.snapshot().root_repo_common_dir(), None); + }); + assert_eq!( + event_count.get(), + 1, + "should have emitted UpdatedRootRepoCommonDir on removal" + ); +} + fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f29e1dd4feecb432c01235762ae7ba251a75f624..a3c80f2a9f25d4665b10e6662047eb968f23532e 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -10,7 +10,7 @@ use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; use clap::Parser; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; -use client::{Client, ProxySettings, UserStore, parse_zed_link}; +use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; use collections::HashMap; use crashes::InitCrashHandler; @@ -664,7 +664,12 @@ fn main() { ); copilot_ui::init(&app_state, cx); - language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.clone(), + cx, + ); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); acp_tools::init(cx); zed::telemetry_log::init(cx); @@ -857,13 +862,13 @@ fn main() { diff_paths, wsl, diff_all: diff_all_mode, + dev_container: args.dev_container, }) } match open_rx - .try_next() + .try_recv() .ok() - .flatten() .and_then(|request| OpenRequest::parse(request, cx).log_err()) { Some(request) => { @@ -1208,6 +1213,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } let mut task = None; + let dev_container = request.dev_container; if !request.open_paths.is_empty() || !request.diff_paths.is_empty() { let app_state = app_state.clone(); task = Some(cx.spawn(async move |cx| { @@ -1218,7 +1224,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut &request.diff_paths, request.diff_all, app_state, - workspace::OpenOptions::default(), + workspace::OpenOptions { + open_in_dev_container: dev_container, + ..Default::default() + }, cx, ) .await?; @@ -1354,16 +1363,10 @@ pub(crate) async fn restore_or_create_workspace( let mut tasks = Vec::new(); for multi_workspace in multi_workspaces { - match restore_multiworkspace(multi_workspace, app_state.clone(), cx).await { - Ok(result) => { - for error in result.errors { - log::error!("Failed to restore workspace in group: {error:#}"); - results.push(Err(error)); - } - } - Err(e) => { - results.push(Err(e)); - } + if let Err(error) = restore_multiworkspace(multi_workspace, app_state.clone(), cx).await + { + log::error!("Failed to restore workspace: {error:#}"); + results.push(Err(error)); } } @@ -1636,6 +1639,13 @@ struct Args { #[arg(long, value_name = "USER@DISTRO")] wsl: Option, + /// Open the project in a dev container. + /// + /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/` + /// configuration is found in the project directory. + #[arg(long)] + dev_container: bool, + /// Instructs zed to run as a dev server on this machine. (not implemented) #[arg(long)] dev_server_token: Option, diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 3cb384b87abc83f7e53e5249b790c01243c2d558..28285ca7a0352960aac204ba9afc1332c297ce0a 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -201,7 +201,12 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> }); prompt_store::init(cx); let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx); - language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + client::RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.clone(), + cx, + ); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); git_ui::init(cx); project::AgentRegistryStore::init_global( @@ -2066,7 +2071,7 @@ fn run_agent_thread_view_test( let mut tool_content: Vec = Vec::new(); let mut tool_locations: Vec = Vec::new(); - while let Ok(Some(event)) = event_receiver.try_next() { + while let Ok(event) = event_receiver.try_recv() { if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( update, ))) = event diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 75fe04feff794f21ff3fdd0e763084e4887a040b..795fd12a6c73d9576095b6cd4a26cdd5577e6000 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -502,12 +502,15 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); let line_ending_indicator = cx.new(|_| line_ending_selector::LineEndingIndicator::default()); + let merge_conflict_indicator = + cx.new(|cx| git_ui::MergeConflictIndicator::new(workspace, cx)); workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(active_file_name, window, cx); status_bar.add_left_item(activity_indicator, window, cx); + status_bar.add_left_item(merge_conflict_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_encoding, window, cx); status_bar.add_right_item(active_buffer_language, window, cx); @@ -5167,6 +5170,7 @@ mod tests { app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); + AppState::set_global(app_state.clone(), cx); theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); @@ -5188,7 +5192,12 @@ mod tests { cx, ); image_viewer::init(cx); - language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + client::RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.clone(), + cx, + ); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); web_search::init(cx); git_graph::init(cx); @@ -5951,7 +5960,9 @@ mod tests { #[gpui::test] async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) { use collections::HashMap; + use project::ProjectGroupKey; use session::Session; + use util::path_list::PathList; use workspace::{OpenMode, Workspace, WorkspaceId}; let app_state = init_test(cx); @@ -6111,94 +6122,50 @@ mod tests { .filter_map(|window| window.downcast::()) .collect() }); + assert_eq!(restored_windows.len(), 2,); + + // Identify restored windows by their active workspace root paths. + let (restored_a, restored_b) = { + let (mut with_dir1, mut with_dir3) = (None, None); + for window in &restored_windows { + let active_paths = window + .read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx)) + .unwrap(); + if active_paths.iter().any(|p| p.as_ref() == Path::new(dir1)) { + with_dir1 = Some(window); + } else { + with_dir3 = Some(window); + } + } + ( + with_dir1.expect("expected a window with dir1 active"), + with_dir3.expect("expected a window with dir3 active"), + ) + }; - assert_eq!( - restored_windows.len(), - 2, - "expected 2 restored windows, got {}", - restored_windows.len() - ); - - let workspace_counts: Vec = restored_windows - .iter() - .map(|window| { - window - .read_with(cx, |multi_workspace, _| multi_workspace.workspaces().len()) - .unwrap() - }) - .collect(); - let mut sorted_counts = workspace_counts.clone(); - sorted_counts.sort(); - assert_eq!( - sorted_counts, - vec![1, 2], - "expected one window with 1 workspace and one with 2, got {workspace_counts:?}" - ); - - let dir1_path: Arc = Path::new(dir1).into(); - let dir2_path: Arc = Path::new(dir2).into(); - let dir3_path: Arc = Path::new(dir3).into(); - - let all_restored_paths: Vec>>> = restored_windows - .iter() - .map(|window| { - window - .read_with(cx, |multi_workspace, cx| { - multi_workspace - .workspaces() - .iter() - .map(|ws| ws.read(cx).root_paths(cx)) - .collect() - }) - .unwrap() + // Window A (dir1+dir2): 1 workspace restored, but 2 project group keys. + restored_a + .read_with(cx, |mw, _| { + assert_eq!( + mw.project_group_keys().cloned().collect::>(), + vec![ + ProjectGroupKey::new(None, PathList::new(&[dir1])), + ProjectGroupKey::new(None, PathList::new(&[dir2])), + ] + ); + assert_eq!(mw.workspaces().len(), 1); }) - .collect(); - - let two_ws_window = all_restored_paths - .iter() - .find(|paths| paths.len() == 2) - .expect("expected a window with 2 workspaces"); - assert!( - two_ws_window.iter().any(|p| p.contains(&dir1_path)), - "2-workspace window should contain dir1, got {two_ws_window:?}" - ); - assert!( - two_ws_window.iter().any(|p| p.contains(&dir2_path)), - "2-workspace window should contain dir2, got {two_ws_window:?}" - ); - - let one_ws_window = all_restored_paths - .iter() - .find(|paths| paths.len() == 1) - .expect("expected a window with 1 workspace"); - assert!( - one_ws_window[0].contains(&dir3_path), - "1-workspace window should contain dir3, got {one_ws_window:?}" - ); - - // --- Verify the active workspace is preserved --- - for window in &restored_windows { - let (active_paths, workspace_count) = window - .read_with(cx, |multi_workspace, cx| { - let active = multi_workspace.workspace(); - ( - active.read(cx).root_paths(cx), - multi_workspace.workspaces().len(), - ) - }) - .unwrap(); + .unwrap(); - if workspace_count == 2 { - assert!( - active_paths.contains(&dir1_path), - "2-workspace window should have dir1 active, got {active_paths:?}" - ); - } else { - assert!( - active_paths.contains(&dir3_path), - "1-workspace window should have dir3 active, got {active_paths:?}" + // Window B (dir3): 1 workspace, 1 project group key. + restored_b + .read_with(cx, |mw, _| { + assert_eq!( + mw.project_group_keys().cloned().collect::>(), + vec![ProjectGroupKey::new(None, PathList::new(&[dir3]))] ); - } - } + assert_eq!(mw.workspaces().len(), 1); + }) + .unwrap(); } } diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 8c9e74a42e6c3ddb2b340ac58da39752009825f0..d09dc07af839a681cea96d43217c4217927864d5 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -313,7 +313,12 @@ mod tests { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); client::init(&app_state.client, cx); - language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + client::RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.clone(), + cx, + ); editor::init(cx); app_state }); diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 7645eae88d69f777f650ac9f86724bfef0f10bc5..0a302291cacc8caa9e0618da00b8d7c6370ccf0e 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -37,6 +37,7 @@ pub struct OpenRequest { pub open_paths: Vec, pub diff_paths: Vec<[String; 2]>, pub diff_all: bool, + pub dev_container: bool, pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, pub remote_connection: Option, @@ -78,6 +79,7 @@ impl OpenRequest { this.diff_paths = request.diff_paths; this.diff_all = request.diff_all; + this.dev_container = request.dev_container; if let Some(wsl) = request.wsl { let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') { if user.is_empty() { @@ -256,6 +258,7 @@ pub struct RawOpenRequest { pub urls: Vec, pub diff_paths: Vec<[String; 2]>, pub diff_all: bool, + pub dev_container: bool, pub wsl: Option, } @@ -413,6 +416,7 @@ pub async fn handle_cli_connection( reuse, env, user_data_dir: _, + dev_container, } => { if !urls.is_empty() { cx.update(|cx| { @@ -421,6 +425,7 @@ pub async fn handle_cli_connection( urls, diff_paths, diff_all, + dev_container, wsl, }, cx, @@ -450,6 +455,7 @@ pub async fn handle_cli_connection( reuse, &responses, wait, + dev_container, app_state.clone(), env, cx, @@ -471,6 +477,7 @@ async fn open_workspaces( reuse: bool, responses: &IpcSender, wait: bool, + dev_container: bool, app_state: Arc, env: Option>, cx: &mut AsyncApp, @@ -532,6 +539,7 @@ async fn open_workspaces( requesting_window: replace_window, wait, env: env.clone(), + open_in_dev_container: dev_container, ..Default::default() }; @@ -1545,4 +1553,123 @@ mod tests { }) .unwrap(); } + + #[gpui::test] + async fn test_dev_container_flag_opens_modal(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| recent_projects::init(cx)); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + ".devcontainer": { + "devcontainer.json": "{}" + }, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let errored = cx + .spawn({ + let app_state = app_state.clone(); + |mut cx| async move { + open_local_workspace( + vec![path!("/project").to_owned()], + vec![], + false, + workspace::OpenOptions { + open_in_dev_container: true, + ..Default::default() + }, + &response_tx, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert!(!errored); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let flag = multi_workspace.workspace().read(cx).open_in_dev_container(); + assert!( + !flag, + "open_in_dev_container flag should be consumed by suggest_on_worktree_updated" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_dev_container_flag_cleared_without_config(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| recent_projects::init(cx)); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let errored = cx + .spawn({ + let app_state = app_state.clone(); + |mut cx| async move { + open_local_workspace( + vec![path!("/project").to_owned()], + vec![], + false, + workspace::OpenOptions { + open_in_dev_container: true, + ..Default::default() + }, + &response_tx, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert!(!errored); + + // Let any pending worktree scan events and updates settle. + cx.run_until_parked(); + + // With no .devcontainer config, the flag should be cleared once the + // worktree scan completes, rather than persisting on the workspace. + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let flag = multi_workspace + .workspace() + .read(cx) + .open_in_dev_container(); + assert!( + !flag, + "open_in_dev_container flag should be cleared when no devcontainer config exists" + ); + }) + .unwrap(); + } } diff --git a/crates/zed/src/zed/windows_only_instance.rs b/crates/zed/src/zed/windows_only_instance.rs index 5790715bc13bdcc68d180519d9176873bd81bc50..f22f49e26a982cb8cb68e21645033819e059de36 100644 --- a/crates/zed/src/zed/windows_only_instance.rs +++ b/crates/zed/src/zed/windows_only_instance.rs @@ -162,6 +162,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> { reuse: false, env: None, user_data_dir: args.user_data_dir.clone(), + dev_container: args.dev_container, } }; diff --git a/crates/zed_credentials_provider/Cargo.toml b/crates/zed_credentials_provider/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..9f64801d4664111bceb0fb7b9ee8c007977b6389 --- /dev/null +++ b/crates/zed_credentials_provider/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "zed_credentials_provider" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/zed_credentials_provider.rs" + +[dependencies] +anyhow.workspace = true +credentials_provider.workspace = true +futures.workspace = true +gpui.workspace = true +paths.workspace = true +release_channel.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/zed_credentials_provider/LICENSE-GPL b/crates/zed_credentials_provider/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/zed_credentials_provider/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/zed_credentials_provider/src/zed_credentials_provider.rs b/crates/zed_credentials_provider/src/zed_credentials_provider.rs new file mode 100644 index 0000000000000000000000000000000000000000..6705e58d400b1a66990f2451d318b5950ea08dde --- /dev/null +++ b/crates/zed_credentials_provider/src/zed_credentials_provider.rs @@ -0,0 +1,181 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::{Arc, LazyLock}; + +use anyhow::Result; +use credentials_provider::CredentialsProvider; +use futures::FutureExt as _; +use gpui::{App, AsyncApp, Global}; +use release_channel::ReleaseChannel; + +/// An environment variable whose presence indicates that the system keychain +/// should be used in development. +/// +/// By default, running Zed in development uses the development credentials +/// provider. Setting this environment variable allows you to interact with the +/// system keychain (for instance, if you need to test something). +/// +/// Only works in development. Setting this environment variable in other +/// release channels is a no-op. +static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock = LazyLock::new(|| { + std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty()) +}); + +pub struct ZedCredentialsProvider(pub Arc); + +impl Global for ZedCredentialsProvider {} + +/// Returns the global [`CredentialsProvider`]. +pub fn init_global(cx: &mut App) { + // The `CredentialsProvider` trait has `Send + Sync` bounds on it, so it + // seems like this is a false positive from Clippy. + #[allow(clippy::arc_with_non_send_sync)] + let provider = new(cx); + cx.set_global(ZedCredentialsProvider(provider)); +} + +pub fn global(cx: &App) -> Arc { + cx.try_global::() + .map(|provider| provider.0.clone()) + .unwrap_or_else(|| new(cx)) +} + +fn new(cx: &App) -> Arc { + let use_development_provider = match ReleaseChannel::try_global(cx) { + Some(ReleaseChannel::Dev) => { + // In development we default to using the development + // credentials provider to avoid getting spammed by relentless + // keychain access prompts. + // + // However, if the `ZED_DEVELOPMENT_USE_KEYCHAIN` environment + // variable is set, we will use the actual keychain. + !*ZED_DEVELOPMENT_USE_KEYCHAIN + } + Some(ReleaseChannel::Nightly | ReleaseChannel::Preview | ReleaseChannel::Stable) | None => { + false + } + }; + + if use_development_provider { + Arc::new(DevelopmentCredentialsProvider::new()) + } else { + Arc::new(KeychainCredentialsProvider) + } +} + +/// A credentials provider that stores credentials in the system keychain. +struct KeychainCredentialsProvider; + +impl CredentialsProvider for KeychainCredentialsProvider { + fn read_credentials<'a>( + &'a self, + url: &'a str, + cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local() + } + + fn write_credentials<'a>( + &'a self, + url: &'a str, + username: &'a str, + password: &'a [u8], + cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { + cx.update(move |cx| cx.write_credentials(url, username, password)) + .await + } + .boxed_local() + } + + fn delete_credentials<'a>( + &'a self, + url: &'a str, + cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local() + } +} + +/// A credentials provider that stores credentials in a local file. +/// +/// This MUST only be used in development, as this is not a secure way of storing +/// credentials on user machines. +/// +/// Its existence is purely to work around the annoyance of having to constantly +/// re-allow access to the system keychain when developing Zed. +struct DevelopmentCredentialsProvider { + path: PathBuf, +} + +impl DevelopmentCredentialsProvider { + fn new() -> Self { + let path = paths::config_dir().join("development_credentials"); + + Self { path } + } + + fn load_credentials(&self) -> Result)>> { + let json = std::fs::read(&self.path)?; + let credentials: HashMap)> = serde_json::from_slice(&json)?; + + Ok(credentials) + } + + fn save_credentials(&self, credentials: &HashMap)>) -> Result<()> { + let json = serde_json::to_string(credentials)?; + std::fs::write(&self.path, json)?; + + Ok(()) + } +} + +impl CredentialsProvider for DevelopmentCredentialsProvider { + fn read_credentials<'a>( + &'a self, + url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + async move { + Ok(self + .load_credentials() + .unwrap_or_default() + .get(url) + .cloned()) + } + .boxed_local() + } + + fn write_credentials<'a>( + &'a self, + url: &'a str, + username: &'a str, + password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { + let mut credentials = self.load_credentials().unwrap_or_default(); + credentials.insert(url.to_string(), (username.to_string(), password.to_vec())); + + self.save_credentials(&credentials) + } + .boxed_local() + } + + fn delete_credentials<'a>( + &'a self, + url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { + let mut credentials = self.load_credentials()?; + credentials.remove(url); + + self.save_credentials(&credentials) + } + .boxed_local() + } +} diff --git a/crates/zed_env_vars/Cargo.toml b/crates/zed_env_vars/Cargo.toml index 1cf32174c351c28ec7eb16deab7b7986655d4a48..bf863b742568f3f607ba7cb54bc8fc267f045cc9 100644 --- a/crates/zed_env_vars/Cargo.toml +++ b/crates/zed_env_vars/Cargo.toml @@ -15,4 +15,4 @@ path = "src/zed_env_vars.rs" default = [] [dependencies] -gpui.workspace = true +env_var.workspace = true diff --git a/crates/zed_env_vars/src/zed_env_vars.rs b/crates/zed_env_vars/src/zed_env_vars.rs index e601cc9536602ac943bd76bf1bfd8b8ac8979dd9..13451911295735762074bcb1cf152470afa55c36 100644 --- a/crates/zed_env_vars/src/zed_env_vars.rs +++ b/crates/zed_env_vars/src/zed_env_vars.rs @@ -1,45 +1,6 @@ -use gpui::SharedString; +pub use env_var::{EnvVar, bool_env_var, env_var}; use std::sync::LazyLock; /// Whether Zed is running in stateless mode. /// When true, Zed will use in-memory databases instead of persistent storage. pub static ZED_STATELESS: LazyLock = bool_env_var!("ZED_STATELESS"); - -#[derive(Clone)] -pub struct EnvVar { - pub name: SharedString, - /// Value of the environment variable. Also `None` when set to an empty string. - pub value: Option, -} - -impl EnvVar { - pub fn new(name: SharedString) -> Self { - let value = std::env::var(name.as_str()).ok(); - if value.as_ref().is_some_and(|v| v.is_empty()) { - Self { name, value: None } - } else { - Self { name, value } - } - } - - pub fn or(self, other: EnvVar) -> EnvVar { - if self.value.is_some() { self } else { other } - } -} - -/// Creates a `LazyLock` expression for use in a `static` declaration. -#[macro_export] -macro_rules! env_var { - ($name:expr) => { - ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) - }; -} - -/// Generates a `LazyLock` expression for use in a `static` declaration. Checks if the -/// environment variable exists and is non-empty. -#[macro_export] -macro_rules! bool_env_var { - ($name:expr) => { - ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) - }; -} diff --git a/crates/zeta_prompt/src/udiff.rs b/crates/zeta_prompt/src/udiff.rs new file mode 100644 index 0000000000000000000000000000000000000000..2658da5893ee923dc0f5798554276f5735abb51a --- /dev/null +++ b/crates/zeta_prompt/src/udiff.rs @@ -0,0 +1,1206 @@ +use std::{ + borrow::Cow, + fmt::{Display, Write}, + mem, + ops::Range, +}; + +use anyhow::{Context as _, Result, anyhow}; + +pub fn strip_diff_path_prefix<'a>(diff: &'a str, prefix: &str) -> Cow<'a, str> { + if prefix.is_empty() { + return Cow::Borrowed(diff); + } + + let prefix_with_slash = format!("{}/", prefix); + let mut needs_rewrite = false; + + for line in diff.lines() { + match DiffLine::parse(line) { + DiffLine::OldPath { path } | DiffLine::NewPath { path } => { + if path.starts_with(&prefix_with_slash) { + needs_rewrite = true; + break; + } + } + _ => {} + } + } + + if !needs_rewrite { + return Cow::Borrowed(diff); + } + + let mut result = String::with_capacity(diff.len()); + for line in diff.lines() { + match DiffLine::parse(line) { + DiffLine::OldPath { path } => { + let stripped = path + .strip_prefix(&prefix_with_slash) + .unwrap_or(path.as_ref()); + result.push_str(&format!("--- a/{}\n", stripped)); + } + DiffLine::NewPath { path } => { + let stripped = path + .strip_prefix(&prefix_with_slash) + .unwrap_or(path.as_ref()); + result.push_str(&format!("+++ b/{}\n", stripped)); + } + _ => { + result.push_str(line); + result.push('\n'); + } + } + } + + Cow::Owned(result) +} + +/// Strip unnecessary git metadata lines from a diff, keeping only the lines +/// needed for patch application: path headers (--- and +++), hunk headers (@@), +/// and content lines (+, -, space). +pub fn strip_diff_metadata(diff: &str) -> String { + let mut result = String::new(); + + for line in diff.lines() { + let dominated = DiffLine::parse(line); + match dominated { + // Keep path headers, hunk headers, and content lines + DiffLine::OldPath { .. } + | DiffLine::NewPath { .. } + | DiffLine::HunkHeader(_) + | DiffLine::Context(_) + | DiffLine::Deletion(_) + | DiffLine::Addition(_) + | DiffLine::NoNewlineAtEOF => { + result.push_str(line); + result.push('\n'); + } + // Skip garbage lines (diff --git, index, etc.) + DiffLine::Garbage(_) => {} + } + } + + result +} + +/// Marker used to encode cursor position in patch comment lines. +pub const CURSOR_POSITION_MARKER: &str = "[CURSOR_POSITION]"; + +/// Extract cursor offset from a patch and return `(clean_patch, cursor_offset)`. +/// +/// Cursor position is encoded as a comment line (starting with `#`) containing +/// `[CURSOR_POSITION]`. A `^` in the line indicates the cursor column; a `<` +/// indicates column 0. The offset is computed relative to addition (`+`) and +/// context (` `) lines accumulated so far in the hunk, which represent the +/// cursor position within the new text contributed by the hunk. +pub fn extract_cursor_from_patch(patch: &str) -> (String, Option) { + let mut clean_patch = String::new(); + let mut cursor_offset: Option = None; + let mut line_start_offset = 0usize; + let mut prev_line_start_offset = 0usize; + + for line in patch.lines() { + let diff_line = DiffLine::parse(line); + + match &diff_line { + DiffLine::Garbage(content) + if content.starts_with('#') && content.contains(CURSOR_POSITION_MARKER) => + { + let caret_column = if let Some(caret_pos) = content.find('^') { + caret_pos + } else if content.find('<').is_some() { + 0 + } else { + continue; + }; + let cursor_column = caret_column.saturating_sub('#'.len_utf8()); + cursor_offset = Some(prev_line_start_offset + cursor_column); + } + _ => { + if !clean_patch.is_empty() { + clean_patch.push('\n'); + } + clean_patch.push_str(line); + + match diff_line { + DiffLine::Addition(content) | DiffLine::Context(content) => { + prev_line_start_offset = line_start_offset; + line_start_offset += content.len() + 1; + } + _ => {} + } + } + } + } + + if patch.ends_with('\n') && !clean_patch.is_empty() { + clean_patch.push('\n'); + } + + (clean_patch, cursor_offset) +} + +/// Find all byte offsets where `hunk.context` occurs as a substring of `text`. +/// +/// If no exact matches are found and the context ends with `'\n'` but `text` +/// does not, retries without the trailing newline, accepting only a match at +/// the very end of `text`. When this fallback fires, the hunk's context is +/// trimmed and its edit ranges are clamped so that downstream code doesn't +/// index past the end of the matched region. This handles diffs that are +/// missing a `\ No newline at end of file` marker: the parser always appends +/// `'\n'` via `writeln!`, so the context can have a trailing newline that +/// doesn't exist in the source text. +pub fn find_context_candidates(text: &str, hunk: &mut Hunk) -> Vec { + let candidates: Vec = text + .match_indices(&hunk.context) + .map(|(offset, _)| offset) + .collect(); + + if !candidates.is_empty() { + return candidates; + } + + if hunk.context.ends_with('\n') && !hunk.context.is_empty() { + let old_len = hunk.context.len(); + hunk.context.pop(); + let new_len = hunk.context.len(); + + if !hunk.context.is_empty() { + let candidates: Vec = text + .match_indices(&hunk.context) + .filter(|(offset, _)| offset + new_len == text.len()) + .map(|(offset, _)| offset) + .collect(); + + if !candidates.is_empty() { + for edit in &mut hunk.edits { + let touched_phantom = edit.range.end > new_len; + edit.range.start = edit.range.start.min(new_len); + edit.range.end = edit.range.end.min(new_len); + if touched_phantom { + // The replacement text was also written with a + // trailing '\n' that corresponds to the phantom + // newline we just removed from the context. + if edit.text.ends_with('\n') { + edit.text.pop(); + } + } + } + return candidates; + } + + // Restore if fallback didn't help either. + hunk.context.push('\n'); + debug_assert_eq!(hunk.context.len(), old_len); + } else { + hunk.context.push('\n'); + } + } + + Vec::new() +} + +/// Given multiple candidate offsets where context matches, use line numbers to disambiguate. +/// Returns the offset that matches the expected line, or None if no match or no line number available. +pub fn disambiguate_by_line_number( + candidates: &[usize], + expected_line: Option, + offset_to_line: &dyn Fn(usize) -> u32, +) -> Option { + match candidates.len() { + 0 => None, + 1 => Some(candidates[0]), + _ => { + let expected = expected_line?; + candidates + .iter() + .copied() + .find(|&offset| offset_to_line(offset) == expected) + } + } +} + +pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { + apply_diff_to_string_with_hunk_offset(diff_str, text).map(|(text, _)| text) +} + +/// Applies a diff to a string and returns the result along with the offset where +/// the first hunk's context matched in the original text. This offset can be used +/// to adjust cursor positions that are relative to the hunk's content. +pub fn apply_diff_to_string_with_hunk_offset( + diff_str: &str, + text: &str, +) -> Result<(String, Option)> { + let mut diff = DiffParser::new(diff_str); + + let mut text = text.to_string(); + let mut first_hunk_offset = None; + + while let Some(event) = diff.next().context("Failed to parse diff")? { + match event { + DiffEvent::Hunk { + mut hunk, + path: _, + status: _, + } => { + let candidates = find_context_candidates(&text, &mut hunk); + + let hunk_offset = + disambiguate_by_line_number(&candidates, hunk.start_line, &|offset| { + text[..offset].matches('\n').count() as u32 + }) + .ok_or_else(|| anyhow!("couldn't resolve hunk"))?; + + if first_hunk_offset.is_none() { + first_hunk_offset = Some(hunk_offset); + } + + for edit in hunk.edits.iter().rev() { + let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); + text.replace_range(range, &edit.text); + } + } + DiffEvent::FileEnd { .. } => {} + } + } + + Ok((text, first_hunk_offset)) +} + +struct PatchFile<'a> { + old_path: Cow<'a, str>, + new_path: Cow<'a, str>, +} + +pub struct DiffParser<'a> { + current_file: Option>, + current_line: Option<(&'a str, DiffLine<'a>)>, + hunk: Hunk, + diff: std::str::Lines<'a>, + pending_start_line: Option, + processed_no_newline: bool, + last_diff_op: LastDiffOp, +} + +#[derive(Clone, Copy, Default)] +enum LastDiffOp { + #[default] + None, + Context, + Deletion, + Addition, +} + +#[derive(Debug, PartialEq)] +pub enum DiffEvent<'a> { + Hunk { + path: Cow<'a, str>, + hunk: Hunk, + status: FileStatus, + }, + FileEnd { + renamed_to: Option>, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FileStatus { + Created, + Modified, + Deleted, +} + +#[derive(Debug, Default, PartialEq)] +pub struct Hunk { + pub context: String, + pub edits: Vec, + pub start_line: Option, +} + +impl Hunk { + pub fn is_empty(&self) -> bool { + self.context.is_empty() && self.edits.is_empty() + } +} + +#[derive(Debug, PartialEq)] +pub struct Edit { + pub range: Range, + pub text: String, +} + +impl<'a> DiffParser<'a> { + pub fn new(diff: &'a str) -> Self { + let mut diff = diff.lines(); + let current_line = diff.next().map(|line| (line, DiffLine::parse(line))); + DiffParser { + current_file: None, + hunk: Hunk::default(), + current_line, + diff, + pending_start_line: None, + processed_no_newline: false, + last_diff_op: LastDiffOp::None, + } + } + + pub fn next(&mut self) -> Result>> { + loop { + let (hunk_done, file_done) = match self.current_line.as_ref().map(|e| &e.1) { + Some(DiffLine::OldPath { .. }) | Some(DiffLine::Garbage(_)) | None => (true, true), + Some(DiffLine::HunkHeader(_)) => (true, false), + _ => (false, false), + }; + + if hunk_done { + if let Some(file) = &self.current_file + && !self.hunk.is_empty() + { + let status = if file.old_path == "/dev/null" { + FileStatus::Created + } else if file.new_path == "/dev/null" { + FileStatus::Deleted + } else { + FileStatus::Modified + }; + let path = if status == FileStatus::Created { + file.new_path.clone() + } else { + file.old_path.clone() + }; + let mut hunk = mem::take(&mut self.hunk); + hunk.start_line = self.pending_start_line.take(); + self.processed_no_newline = false; + self.last_diff_op = LastDiffOp::None; + return Ok(Some(DiffEvent::Hunk { path, hunk, status })); + } + } + + if file_done { + if let Some(PatchFile { old_path, new_path }) = self.current_file.take() { + return Ok(Some(DiffEvent::FileEnd { + renamed_to: if old_path != new_path && old_path != "/dev/null" { + Some(new_path) + } else { + None + }, + })); + } + } + + let Some((line, parsed_line)) = self.current_line.take() else { + break; + }; + + (|| { + match parsed_line { + DiffLine::OldPath { path } => { + self.current_file = Some(PatchFile { + old_path: path, + new_path: "".into(), + }); + } + DiffLine::NewPath { path } => { + if let Some(current_file) = &mut self.current_file { + current_file.new_path = path + } + } + DiffLine::HunkHeader(location) => { + if let Some(loc) = location { + self.pending_start_line = Some(loc.start_line_old); + } + } + DiffLine::Context(ctx) => { + if self.current_file.is_some() { + writeln!(&mut self.hunk.context, "{ctx}")?; + self.last_diff_op = LastDiffOp::Context; + } + } + DiffLine::Deletion(del) => { + if self.current_file.is_some() { + let range = self.hunk.context.len() + ..self.hunk.context.len() + del.len() + '\n'.len_utf8(); + if let Some(last_edit) = self.hunk.edits.last_mut() + && last_edit.range.end == range.start + { + last_edit.range.end = range.end; + } else { + self.hunk.edits.push(Edit { + range, + text: String::new(), + }); + } + writeln!(&mut self.hunk.context, "{del}")?; + self.last_diff_op = LastDiffOp::Deletion; + } + } + DiffLine::Addition(add) => { + if self.current_file.is_some() { + let range = self.hunk.context.len()..self.hunk.context.len(); + if let Some(last_edit) = self.hunk.edits.last_mut() + && last_edit.range.end == range.start + { + writeln!(&mut last_edit.text, "{add}").unwrap(); + } else { + self.hunk.edits.push(Edit { + range, + text: format!("{add}\n"), + }); + } + self.last_diff_op = LastDiffOp::Addition; + } + } + DiffLine::NoNewlineAtEOF => { + if !self.processed_no_newline { + self.processed_no_newline = true; + match self.last_diff_op { + LastDiffOp::Addition => { + // Remove trailing newline from the last addition + if let Some(last_edit) = self.hunk.edits.last_mut() { + last_edit.text.pop(); + } + } + LastDiffOp::Deletion => { + // Remove trailing newline from context (which includes the deletion) + self.hunk.context.pop(); + if let Some(last_edit) = self.hunk.edits.last_mut() { + last_edit.range.end -= 1; + } + } + LastDiffOp::Context | LastDiffOp::None => { + // Remove trailing newline from context + self.hunk.context.pop(); + } + } + } + } + DiffLine::Garbage(_) => {} + } + + anyhow::Ok(()) + })() + .with_context(|| format!("on line:\n\n```\n{}```", line))?; + + self.current_line = self.diff.next().map(|line| (line, DiffLine::parse(line))); + } + + anyhow::Ok(None) + } +} + +#[derive(Debug, PartialEq)] +pub enum DiffLine<'a> { + OldPath { path: Cow<'a, str> }, + NewPath { path: Cow<'a, str> }, + HunkHeader(Option), + Context(&'a str), + Deletion(&'a str), + Addition(&'a str), + NoNewlineAtEOF, + Garbage(&'a str), +} + +#[derive(Debug, PartialEq)] +pub struct HunkLocation { + pub start_line_old: u32, + pub count_old: u32, + pub start_line_new: u32, + pub count_new: u32, +} + +impl<'a> DiffLine<'a> { + pub fn parse(line: &'a str) -> Self { + Self::try_parse(line).unwrap_or(Self::Garbage(line)) + } + + fn try_parse(line: &'a str) -> Option { + if line.starts_with("\\ No newline") { + return Some(Self::NoNewlineAtEOF); + } + if let Some(header) = line.strip_prefix("---").and_then(eat_required_whitespace) { + let path = parse_header_path("a/", header); + Some(Self::OldPath { path }) + } else if let Some(header) = line.strip_prefix("+++").and_then(eat_required_whitespace) { + Some(Self::NewPath { + path: parse_header_path("b/", header), + }) + } else if let Some(header) = line.strip_prefix("@@").and_then(eat_required_whitespace) { + if header.starts_with("...") { + return Some(Self::HunkHeader(None)); + } + + let mut tokens = header.split_whitespace(); + let old_range = tokens.next()?.strip_prefix('-')?; + let new_range = tokens.next()?.strip_prefix('+')?; + + let (start_line_old, count_old) = old_range.split_once(',').unwrap_or((old_range, "1")); + let (start_line_new, count_new) = new_range.split_once(',').unwrap_or((new_range, "1")); + + Some(Self::HunkHeader(Some(HunkLocation { + start_line_old: start_line_old.parse::().ok()?.saturating_sub(1), + count_old: count_old.parse().ok()?, + start_line_new: start_line_new.parse::().ok()?.saturating_sub(1), + count_new: count_new.parse().ok()?, + }))) + } else if let Some(deleted_header) = line.strip_prefix("-") { + Some(Self::Deletion(deleted_header)) + } else if line.is_empty() { + Some(Self::Context("")) + } else if let Some(context) = line.strip_prefix(" ") { + Some(Self::Context(context)) + } else { + Some(Self::Addition(line.strip_prefix("+")?)) + } + } +} + +impl<'a> Display for DiffLine<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiffLine::OldPath { path } => write!(f, "--- {path}"), + DiffLine::NewPath { path } => write!(f, "+++ {path}"), + DiffLine::HunkHeader(Some(hunk_location)) => { + write!( + f, + "@@ -{},{} +{},{} @@", + hunk_location.start_line_old + 1, + hunk_location.count_old, + hunk_location.start_line_new + 1, + hunk_location.count_new + ) + } + DiffLine::HunkHeader(None) => write!(f, "@@ ... @@"), + DiffLine::Context(content) => write!(f, " {content}"), + DiffLine::Deletion(content) => write!(f, "-{content}"), + DiffLine::Addition(content) => write!(f, "+{content}"), + DiffLine::NoNewlineAtEOF => write!(f, "\\ No newline at end of file"), + DiffLine::Garbage(line) => write!(f, "{line}"), + } + } +} + +fn parse_header_path<'a>(strip_prefix: &'static str, header: &'a str) -> Cow<'a, str> { + if !header.contains(['"', '\\']) { + let path = header.split_ascii_whitespace().next().unwrap_or(header); + return Cow::Borrowed(path.strip_prefix(strip_prefix).unwrap_or(path)); + } + + let mut path = String::with_capacity(header.len()); + let mut in_quote = false; + let mut chars = header.chars().peekable(); + let mut strip_prefix = Some(strip_prefix); + + while let Some(char) = chars.next() { + if char == '"' { + in_quote = !in_quote; + } else if char == '\\' { + let Some(&next_char) = chars.peek() else { + break; + }; + chars.next(); + path.push(next_char); + } else if char.is_ascii_whitespace() && !in_quote { + break; + } else { + path.push(char); + } + + if let Some(prefix) = strip_prefix + && path == prefix + { + strip_prefix.take(); + path.clear(); + } + } + + Cow::Owned(path) +} + +fn eat_required_whitespace(header: &str) -> Option<&str> { + let trimmed = header.trim_ascii_start(); + + if trimmed.len() == header.len() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn parse_lines_simple() { + let input = indoc! {" + diff --git a/text.txt b/text.txt + index 86c770d..a1fd855 100644 + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,3 @@ + context + -deleted + +inserted + garbage + + --- b/file.txt + +++ a/file.txt + "}; + + let lines = input.lines().map(DiffLine::parse).collect::>(); + + assert_eq!( + lines, + &[ + DiffLine::Garbage("diff --git a/text.txt b/text.txt"), + DiffLine::Garbage("index 86c770d..a1fd855 100644"), + DiffLine::OldPath { + path: "file.txt".into() + }, + DiffLine::NewPath { + path: "file.txt".into() + }, + DiffLine::HunkHeader(Some(HunkLocation { + start_line_old: 0, + count_old: 2, + start_line_new: 0, + count_new: 3 + })), + DiffLine::Context("context"), + DiffLine::Deletion("deleted"), + DiffLine::Addition("inserted"), + DiffLine::Garbage("garbage"), + DiffLine::Context(""), + DiffLine::OldPath { + path: "b/file.txt".into() + }, + DiffLine::NewPath { + path: "a/file.txt".into() + }, + ] + ); + } + + #[test] + fn file_header_extra_space() { + let options = ["--- file", "--- file", "---\tfile"]; + + for option in options { + assert_eq!( + DiffLine::parse(option), + DiffLine::OldPath { + path: "file".into() + }, + "{option}", + ); + } + } + + #[test] + fn hunk_header_extra_space() { + let options = [ + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@", + "@@\t-1,2\t+1,3\t@@", + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@", + "@@ -1,2 +1,3 @@ garbage", + ]; + + for option in options { + assert_eq!( + DiffLine::parse(option), + DiffLine::HunkHeader(Some(HunkLocation { + start_line_old: 0, + count_old: 2, + start_line_new: 0, + count_new: 3 + })), + "{option}", + ); + } + } + + #[test] + fn hunk_header_without_location() { + assert_eq!(DiffLine::parse("@@ ... @@"), DiffLine::HunkHeader(None)); + } + + #[test] + fn test_parse_path() { + assert_eq!(parse_header_path("a/", "foo.txt"), "foo.txt"); + assert_eq!( + parse_header_path("a/", "foo/bar/baz.txt"), + "foo/bar/baz.txt" + ); + assert_eq!(parse_header_path("a/", "a/foo.txt"), "foo.txt"); + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt"), + "foo/bar/baz.txt" + ); + + // Extra + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt 2025"), + "foo/bar/baz.txt" + ); + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt\t2025"), + "foo/bar/baz.txt" + ); + assert_eq!( + parse_header_path("a/", "a/foo/bar/baz.txt \""), + "foo/bar/baz.txt" + ); + + // Quoted + assert_eq!( + parse_header_path("a/", "a/foo/bar/\"baz quox.txt\""), + "foo/bar/baz quox.txt" + ); + assert_eq!( + parse_header_path("a/", "\"a/foo/bar/baz quox.txt\""), + "foo/bar/baz quox.txt" + ); + assert_eq!( + parse_header_path("a/", "\"foo/bar/baz quox.txt\""), + "foo/bar/baz quox.txt" + ); + assert_eq!(parse_header_path("a/", "\"whatever 🤷\""), "whatever 🤷"); + assert_eq!( + parse_header_path("a/", "\"foo/bar/baz quox.txt\" 2025"), + "foo/bar/baz quox.txt" + ); + // unescaped quotes are dropped + assert_eq!(parse_header_path("a/", "foo/\"bar\""), "foo/bar"); + + // Escaped + assert_eq!( + parse_header_path("a/", "\"foo/\\\"bar\\\"/baz.txt\""), + "foo/\"bar\"/baz.txt" + ); + assert_eq!( + parse_header_path("a/", "\"C:\\\\Projects\\\\My App\\\\old file.txt\""), + "C:\\Projects\\My App\\old file.txt" + ); + } + + #[test] + fn test_parse_diff_with_leading_and_trailing_garbage() { + let diff = indoc! {" + I need to make some changes. + + I'll change the following things: + - one + - two + - three + + ``` + --- a/file.txt + +++ b/file.txt + one + +AND + two + ``` + + Summary of what I did: + - one + - two + - three + + That's about it. + "}; + + let mut events = Vec::new(); + let mut parser = DiffParser::new(diff); + while let Some(event) = parser.next().unwrap() { + events.push(event); + } + + assert_eq!( + events, + &[ + DiffEvent::Hunk { + path: "file.txt".into(), + hunk: Hunk { + context: "one\ntwo\n".into(), + edits: vec![Edit { + range: 4..4, + text: "AND\n".into() + }], + start_line: None, + }, + status: FileStatus::Modified, + }, + DiffEvent::FileEnd { renamed_to: None } + ], + ) + } + + #[test] + fn test_no_newline_at_eof() { + let diff = indoc! {" + --- a/file.py + +++ b/file.py + @@ -55,7 +55,3 @@ class CustomDataset(Dataset): + torch.set_rng_state(state) + mask = self.transform(mask) + + - if self.mode == 'Training': + - return (img, mask, name) + - else: + - return (img, mask, name) + \\ No newline at end of file + "}; + + let mut events = Vec::new(); + let mut parser = DiffParser::new(diff); + while let Some(event) = parser.next().unwrap() { + events.push(event); + } + + assert_eq!( + events, + &[ + DiffEvent::Hunk { + path: "file.py".into(), + hunk: Hunk { + context: concat!( + " torch.set_rng_state(state)\n", + " mask = self.transform(mask)\n", + "\n", + " if self.mode == 'Training':\n", + " return (img, mask, name)\n", + " else:\n", + " return (img, mask, name)", + ) + .into(), + edits: vec![Edit { + range: 80..203, + text: "".into() + }], + start_line: Some(54), // @@ -55,7 -> line 54 (0-indexed) + }, + status: FileStatus::Modified, + }, + DiffEvent::FileEnd { renamed_to: None } + ], + ); + } + + #[test] + fn test_no_newline_at_eof_addition() { + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,3 @@ + context + -deleted + +added line + \\ No newline at end of file + "}; + + let mut events = Vec::new(); + let mut parser = DiffParser::new(diff); + while let Some(event) = parser.next().unwrap() { + events.push(event); + } + + assert_eq!( + events, + &[ + DiffEvent::Hunk { + path: "file.txt".into(), + hunk: Hunk { + context: "context\ndeleted\n".into(), + edits: vec![Edit { + range: 8..16, + text: "added line".into() + }], + start_line: Some(0), // @@ -1,2 -> line 0 (0-indexed) + }, + status: FileStatus::Modified, + }, + DiffEvent::FileEnd { renamed_to: None } + ], + ); + } + + #[test] + fn test_double_no_newline_at_eof() { + // Two consecutive "no newline" markers - the second should be ignored + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,3 @@ + line1 + -old + +new + line3 + \\ No newline at end of file + \\ No newline at end of file + "}; + + let mut events = Vec::new(); + let mut parser = DiffParser::new(diff); + while let Some(event) = parser.next().unwrap() { + events.push(event); + } + + assert_eq!( + events, + &[ + DiffEvent::Hunk { + path: "file.txt".into(), + hunk: Hunk { + context: "line1\nold\nline3".into(), // Only one newline removed + edits: vec![Edit { + range: 6..10, // "old\n" is 4 bytes + text: "new\n".into() + }], + start_line: Some(0), + }, + status: FileStatus::Modified, + }, + DiffEvent::FileEnd { renamed_to: None } + ], + ); + } + + #[test] + fn test_no_newline_after_context_not_addition() { + // "No newline" after context lines should remove newline from context, + // not from an earlier addition + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,4 +1,4 @@ + line1 + -old + +new + line3 + line4 + \\ No newline at end of file + "}; + + let mut events = Vec::new(); + let mut parser = DiffParser::new(diff); + while let Some(event) = parser.next().unwrap() { + events.push(event); + } + + assert_eq!( + events, + &[ + DiffEvent::Hunk { + path: "file.txt".into(), + hunk: Hunk { + // newline removed from line4 (context), not from "new" (addition) + context: "line1\nold\nline3\nline4".into(), + edits: vec![Edit { + range: 6..10, // "old\n" is 4 bytes + text: "new\n".into() // Still has newline + }], + start_line: Some(0), + }, + status: FileStatus::Modified, + }, + DiffEvent::FileEnd { renamed_to: None } + ], + ); + } + + #[test] + fn test_strip_diff_metadata() { + let diff_with_metadata = indoc! {r#" + diff --git a/file.txt b/file.txt + index 1234567..abcdefg 100644 + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,4 @@ + context line + -removed line + +added line + more context + "#}; + + let stripped = strip_diff_metadata(diff_with_metadata); + + assert_eq!( + stripped, + indoc! {r#" + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,4 @@ + context line + -removed line + +added line + more context + "#} + ); + } + + #[test] + fn test_apply_diff_to_string_no_trailing_newline() { + // Text without trailing newline; diff generated without + // `\ No newline at end of file` marker. + let text = "line1\nline2\nline3"; + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,3 @@ + line1 + -line2 + +replaced + line3 + "}; + + let result = apply_diff_to_string(diff, text).unwrap(); + assert_eq!(result, "line1\nreplaced\nline3"); + } + + #[test] + fn test_apply_diff_to_string_trailing_newline_present() { + // When text has a trailing newline, exact matching still works and + // the fallback is never needed. + let text = "line1\nline2\nline3\n"; + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,3 @@ + line1 + -line2 + +replaced + line3 + "}; + + let result = apply_diff_to_string(diff, text).unwrap(); + assert_eq!(result, "line1\nreplaced\nline3\n"); + } + + #[test] + fn test_apply_diff_to_string_deletion_at_end_no_trailing_newline() { + // Deletion of the last line when text has no trailing newline. + // The edit range must be clamped so it doesn't index past the + // end of the text. + let text = "line1\nline2\nline3"; + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,2 @@ + line1 + line2 + -line3 + "}; + + let result = apply_diff_to_string(diff, text).unwrap(); + assert_eq!(result, "line1\nline2\n"); + } + + #[test] + fn test_apply_diff_to_string_replace_last_line_no_trailing_newline() { + // Replace the last line when text has no trailing newline. + let text = "aaa\nbbb\nccc"; + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,3 +1,3 @@ + aaa + bbb + -ccc + +ddd + "}; + + let result = apply_diff_to_string(diff, text).unwrap(); + assert_eq!(result, "aaa\nbbb\nddd"); + } + + #[test] + fn test_apply_diff_to_string_multibyte_no_trailing_newline() { + // Multi-byte UTF-8 characters near the end; ensures char boundary + // safety when the fallback clamps edit ranges. + let text = "hello\n세계"; + let diff = indoc! {" + --- a/file.txt + +++ b/file.txt + @@ -1,2 +1,2 @@ + hello + -세계 + +world + "}; + + let result = apply_diff_to_string(diff, text).unwrap(); + assert_eq!(result, "hello\nworld"); + } + + #[test] + fn test_find_context_candidates_no_false_positive_mid_text() { + // The stripped fallback must only match at the end of text, not in + // the middle where a real newline exists. + let text = "aaa\nbbb\nccc\n"; + let mut hunk = Hunk { + context: "bbb\n".into(), + edits: vec![], + start_line: None, + }; + + let candidates = find_context_candidates(text, &mut hunk); + // Exact match at offset 4 — the fallback is not used. + assert_eq!(candidates, vec![4]); + } + + #[test] + fn test_find_context_candidates_fallback_at_end() { + let text = "aaa\nbbb"; + let mut hunk = Hunk { + context: "bbb\n".into(), + edits: vec![], + start_line: None, + }; + + let candidates = find_context_candidates(text, &mut hunk); + assert_eq!(candidates, vec![4]); + // Context should be stripped. + assert_eq!(hunk.context, "bbb"); + } + + #[test] + fn test_find_context_candidates_no_fallback_mid_text() { + // "bbb" appears mid-text followed by a newline, so the exact + // match succeeds. Verify the stripped fallback doesn't produce a + // second, spurious candidate. + let text = "aaa\nbbb\nccc"; + let mut hunk = Hunk { + context: "bbb\nccc\n".into(), + edits: vec![], + start_line: None, + }; + + let candidates = find_context_candidates(text, &mut hunk); + // No exact match (text ends without newline after "ccc"), but the + // stripped context "bbb\nccc" matches at offset 4, which is the end. + assert_eq!(candidates, vec![4]); + assert_eq!(hunk.context, "bbb\nccc"); + } + + #[test] + fn test_find_context_candidates_clamps_edit_ranges() { + let text = "aaa\nbbb"; + let mut hunk = Hunk { + context: "aaa\nbbb\n".into(), + edits: vec![Edit { + range: 4..8, // "bbb\n" — end points at the trailing \n + text: "ccc\n".into(), + }], + start_line: None, + }; + + let candidates = find_context_candidates(text, &mut hunk); + assert_eq!(candidates, vec![0]); + // Edit range end should be clamped to 7 (new context length). + assert_eq!(hunk.edits[0].range, 4..7); + } +} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index e3aced7ed81d8bf3835a3e711e472651764a314e..0d72d6cd7a46782aa4b572a4ef564d5fe3dec417 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -1,5 +1,6 @@ pub mod excerpt_ranges; pub mod multi_region; +pub mod udiff; use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; @@ -819,6 +820,113 @@ pub fn encode_patch_as_output_for_format( } } +/// Given a `ZetaPromptInput`, a format, and a patch (with cursor already +/// extracted), produce the expected model output string for training. +pub fn format_expected_output( + input: &ZetaPromptInput, + format: ZetaFormat, + patch: &str, + cursor_offset: Option, +) -> Result { + let (context, editable_range, _, _) = resolve_cursor_region(input, format); + let mut old_editable = context[editable_range].to_string(); + if !old_editable.is_empty() && !old_editable.ends_with('\n') { + old_editable.push('\n'); + } + + // Formats with their own output encoding (hashline, variable-edit, + // multi-region empty patches) are handled here. + if let Some(output) = + encode_patch_as_output_for_format(format, &old_editable, patch, cursor_offset)? + { + return Ok(output); + } + + let empty_patch = patch.lines().count() <= 3; + + match format { + // Multi-region formats: non-empty patches need diff application + // then marker-span encoding. + ZetaFormat::V0316SeedMultiRegions => { + let (new_editable, first_hunk_offset) = + udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable)?; + let cursor_in_new = cursor_in_new_text(cursor_offset, first_hunk_offset, &new_editable); + multi_region::encode_from_old_and_new_v0316( + &old_editable, + &new_editable, + cursor_in_new, + CURSOR_MARKER, + multi_region::V0316_END_MARKER, + ) + } + ZetaFormat::V0318SeedMultiRegions => { + let (new_editable, first_hunk_offset) = + udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable)?; + let cursor_in_new = cursor_in_new_text(cursor_offset, first_hunk_offset, &new_editable); + multi_region::encode_from_old_and_new_v0318( + &old_editable, + &new_editable, + cursor_in_new, + CURSOR_MARKER, + multi_region::V0318_END_MARKER, + ) + } + ZetaFormat::V0317SeedMultiRegions => { + let (new_editable, first_hunk_offset) = + udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable)?; + let cursor_in_new = cursor_in_new_text(cursor_offset, first_hunk_offset, &new_editable); + multi_region::encode_from_old_and_new_v0317( + &old_editable, + &new_editable, + cursor_in_new, + CURSOR_MARKER, + multi_region::V0317_END_MARKER, + ) + } + // V0131-style formats and fallback: produce new editable text with + // cursor marker inserted, followed by the end marker. + _ => { + let (mut result, first_hunk_offset) = if empty_patch { + (old_editable.clone(), None) + } else { + udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable)? + }; + + if let Some(cursor) = cursor_offset { + let hunk_start = if !empty_patch { + first_hunk_offset.unwrap_or(0) + } else { + 0 + }; + let offset = (hunk_start + cursor).min(result.len()); + result.insert_str(offset, CURSOR_MARKER); + } + + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + + if let Some(end_marker) = output_end_marker_for_format(format) { + result.push_str(end_marker); + } + + Ok(result) + } + } +} + +/// Compute the cursor position within the new text after diff application. +fn cursor_in_new_text( + cursor_offset: Option, + first_hunk_offset: Option, + new_text: &str, +) -> Option { + cursor_offset.map(|cursor| { + let hunk_start = first_hunk_offset.unwrap_or(0); + (hunk_start + cursor).min(new_text.len()) + }) +} + pub struct ParsedOutput { /// Text that should replace the editable region pub new_editable_region: String, diff --git a/docs/README.md b/docs/README.md index f03f008223ba1102585c34f3b98bf93a985c1284..38be153de34b7e32e410fa67710297cca653d699 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,12 +4,15 @@ Welcome to Zed's documentation. This is built on push to `main` and published automatically to [https://zed.dev/docs](https://zed.dev/docs). -To preview the docs locally you will need to install [mdBook](https://rust-lang.github.io/mdBook/) (`cargo install mdbook@0.4.40`) and then run: +To preview the docs locally you will need to install [mdBook](https://rust-lang.github.io/mdBook/) (`cargo install mdbook@0.4.40`), generate the action metadata, and then serve: ```sh +script/generate-action-metadata mdbook serve docs ``` +The first command dumps an action manifest to `crates/docs_preprocessor/actions.json`. Without it, the preprocessor cannot validate keybinding and action references in the docs and will report errors. You only need to re-run it when actions change. + It's important to note the version number above. For an unknown reason, as of 2025-04-23, running 0.4.48 will cause odd URL behavior that breaks things. Before committing, verify that the docs are formatted in the way Prettier expects with: diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 2da2f37a67edea48e0c34b14cab1ec0fc81a522b..89b0126c55a12b08d4f21a01fea38758c4d509b7 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -67,7 +67,9 @@ Right-click on any agent response in the thread view to access a context menu wi ### Navigating the Thread {#navigating-the-thread} -In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. +In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. You can also scroll the thread using arrow keys, Page Up/Down, Home/End, and Shift+Page Up/Down to jump between messages, when the thread pane is focused. + +When focus is in the message editor, you can also use {#kb agent::ScrollOutputPageUp}, {#kb agent::ScrollOutputPageDown}, {#kb agent::ScrollOutputToTop}, {#kb agent::ScrollOutputToBottom}, {#kb agent::ScrollOutputLineUp}, and {#kb agent::ScrollOutputLineDown} to navigate the thread, or {#kb agent::ScrollOutputToPreviousMessage} and {#kb agent::ScrollOutputToNextMessage} to jump between your prompts. ### Navigating History {#navigating-history} diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 46bed8e223721be81806a3662752d3a4533ab173..01c16dc62be8b9be7e576bc1be10f20437acc993 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -173,8 +173,6 @@ git submodule add https://github.com/your-username/foobar-zed.git extensions/my- git add extensions/my-extension ``` -> **Note:** Your extension must live under te - > All extension submodules must use HTTPS URLs and not SSH URLS (`git@github.com`). 2. Add a new entry to the top-level `extensions.toml` file containing your extension: diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index c8e6958db683a5a3e2c9903c590f564b0ef4cb93..121357306e73552140f938197ffc466c0e489484 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -143,6 +143,21 @@ This query marks strings, object keys, and numbers for highlighting. The followi | @variable.parameter | Captures function/method parameters | | @variant | Captures variants | +#### Fallback captures + +A single Tree-sitter pattern can specify multiple captures on the same node to define fallback highlights. +Zed resolves them right-to-left: It first tries the rightmost capture, and if the current theme has no style for it, falls back to the next capture to the left, and so on. + +For example: + +```scheme +(type_identifier) @type @variable +``` + +Here Zed will first try to resolve `@variable` from the theme. If the theme defines a style for `@variable`, that style is used. Otherwise, Zed falls back to `@type`. + +This is useful when a language wants to provide a preferred highlight that not all themes may support, while still falling back to a more common capture that most themes define. + ### Bracket matching The `brackets.scm` file defines matching brackets. diff --git a/docs/src/performance.md b/docs/src/performance.md index b8f76179e16fcf1f1b886a5c3ef00bcc85aa9ed4..d25ac246f3dbc03ba4286f8e130c566657bbf196 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. -image +image # In depth CPU profiling (Tracing) @@ -53,20 +53,40 @@ Download the profiler: Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. -image +image Tracy is an incredibly powerful profiler which can do a lot however it's UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow. Here are the steps: 1. Click the flamechart button at the top. + +Click flamechart + 2. Click on a function that takes a lot of time. + +Click snapshot + 3. Expand the list of function calls by clicking on main thread. + +Click main thread + 4. Filter that list to the slower calls then click on one of the slow calls in the list + +Select the tail calls in the histogram to filter down the list of calls then click on one call + 5. Click zoom to zone to go to that specific function call in the timeline + +Click zoom to zone + 6. Scroll to zoom in and see more detail about the callers + +Scroll to zoom in + 7. Click on a caller to to get statistics on _it_. +Click on any of the zones to get statistics + While normally the blue bars in the Tracy timeline correspond to function calls they can time any part of a codebase. In the example below we have added an extra span "for block in edits" and added metadata to it: the block_height. You can do that like this: ```rust @@ -74,14 +94,6 @@ let span = ztracing::debug_span!("for block in edits", block_height = block.heig let _enter = span.enter(); // span guard, when this is dropped the span ends (and its duration is recorded) ``` -Click flamechart -Click snapshot -Click main thread -Select the tail calls in the histogram to filter down the list of calls then click on one call -Click zoom to zone -Scroll to zoom in -Click on any of the zones to get statistics - # Task/Async profiling Get a profile of the zed foreground executor and background executors. Check if diff --git a/docs/src/tasks.md b/docs/src/tasks.md index b4c9ba8a2abf5ce03e4a9a43fe7fc7e55f9240a4..3bbef85e9760ad036b75d50f26d3536b2e5b20f1 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -53,9 +53,9 @@ Zed supports ways to spawn (and rerun) commands using its integrated [terminal]( "show_command": true, // Which edited buffers to save before running the task: // * `all` — save all edited buffers - // * `current` — save current buffer only + // * `current` — save currently active buffer only // * `none` — don't save any buffers - "save": "all" + "save": "none" // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] } diff --git a/docs/src/troubleshooting.md b/docs/src/troubleshooting.md index a852ce779cdb0b719a56e3b12d68ee9b2baab6b7..0ec95cd55e0d127e82430670de9290ec793deb5d 100644 --- a/docs/src/troubleshooting.md +++ b/docs/src/troubleshooting.md @@ -45,13 +45,13 @@ Xcode Instruments (which comes bundled with your [Xcode](https://apps.apple.com/ 1. With Zed running, open Instruments 1. Select `Time Profiler` as the profiling template - ![Instruments template picker with Time Profiler selected](https://images.zed.dev/troubleshooting/instruments-template-picker.webp) + ![Instruments template picker with Time Profiler selected](https://images.zed.dev/docs/troubleshooting/instruments-template-picker.webp) 1. In the `Time Profiler` configuration, set the target to the running Zed process 1. Start recording - ![Time Profiler configuration showing the target dropdown and record button](https://images.zed.dev/troubleshooting/instruments-target-and-record.webp) + ![Time Profiler configuration showing the target dropdown and record button](https://images.zed.dev/docs/troubleshooting/instruments-target-and-record.webp) 1. Perform the action in Zed that causes performance issues 1. Stop recording - ![A completed Time Profiler recording in Instruments](https://images.zed.dev/troubleshooting/instruments-recording.webp) + ![A completed Time Profiler recording in Instruments](https://images.zed.dev/docs/troubleshooting/instruments-recording.webp) 1. Save the trace file 1. Compress the trace file into a zip archive 1. File a [GitHub issue](https://github.com/zed-industries/zed/issues/new/choose) with the trace zip attached diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css index f63fd24d1379aa3f325ba53a92784ba256a0dd97..9c8077bad525da1b7c15572d6fc154b66602e987 100644 --- a/docs/theme/css/general.css +++ b/docs/theme/css/general.css @@ -70,10 +70,21 @@ h5, h6 { position: relative; font-family: var(--title-font); - font-weight: 480; + font-weight: 400; +} + +h1 { color: var(--title-color); } +h2, +h3, +h4, +h5, +h6 { + color: var(--full-contrast); +} + /* Don't change font size in headers. */ h1 code, h2 code, @@ -213,7 +224,7 @@ hr { } .content { - padding: 48px 32px 0 32px; + padding: 32px 32px 0 32px; display: flex; justify-content: space-between; gap: 36px; @@ -272,10 +283,14 @@ hr { border-radius: 8px; overflow: clip; } -.content .header:link, -.content .header:visited { +.content h1 .header:link, +.content h1 .header:visited { color: var(--title-color); } +.content :is(h2, h3, h4, h5, h6) .header:link, +.content :is(h2, h3, h4, h5, h6) .header:visited { + color: var(--full-contrast); +} .content .header:link, .content .header:visited:hover { text-decoration: none; @@ -383,15 +398,17 @@ blockquote .warning:before { } kbd { - background-color: rgba(8, 76, 207, 0.1); + background-color: var(--keybinding-bg); + padding: 4px 4px 6px 4px; border-radius: 4px; + font-family: var(--mono-font); + display: inline-block; + margin: 0 2px; border: solid 1px var(--popover-border); box-shadow: inset 0 -1px 0 var(--theme-hover); - display: inline-block; font-size: var(--code-font-size); - font-family: var(--mono-font); + color: var(--full-contrast); line-height: 10px; - padding: 4px 5px; vertical-align: middle; } diff --git a/docs/theme/css/variables.css b/docs/theme/css/variables.css index 46ea739daf8643db5ad57a239091e557df2a3d0c..ca43e6feb4a17d67ce0a6140ba1459569bb6e33f 100644 --- a/docs/theme/css/variables.css +++ b/docs/theme/css/variables.css @@ -11,11 +11,12 @@ --page-padding: 15px; --content-max-width: 690px; --menu-bar-height: 64px; - --font: "IA Writer Quattro S", sans-serif; - --title-font: "Lora", "Helvetica Neue", Helvetica, Arial, sans-serif; + --font: "iA Writer Quattro S", sans-serif; + --title-font: + "IBM Plex Serif", "Helvetica Neue", Helvetica, Arial, sans-serif; --mono-font: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, - Courier New, monospace; + "Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + Liberation Mono, Courier New, monospace; --code-font-size: 0.875em /* please adjust the ace font size accordingly in editor.js */; @@ -151,7 +152,7 @@ --inline-code-color: hsl(40, 100%, 80%); --code-text: hsl(220, 13%, 95%); --code-bg: hsl(220, 93%, 50%, 0.2); - --keybinding-bg: hsl(0, 0%, 12%); + --keybinding-bg: hsl(220, 20%, 10%); --pre-bg: hsl(220, 13%, 5%); --pre-border: hsla(220, 93%, 70%, 0.3); @@ -162,7 +163,7 @@ --popover-shadow: 0 10px 15px -3px hsl(0, 0%, 0%, 0.1), 0 4px 6px -4px hsl(0, 0%, 0%, 0.1); - --theme-hover: hsl(220, 13%, 25%); + --theme-hover: hsl(220, 13%, 20%); --hover-section-title: hsl(220, 13%, 11%); --quote-bg: hsl(220, 13%, 25%, 0.4); diff --git a/docs/theme/fonts/Lora.var.woff2 b/docs/theme/fonts/Lora.var.woff2 deleted file mode 100644 index e2d8990a7ee9fe1f2b02c5d9c23b1e8e13e14de9..0000000000000000000000000000000000000000 Binary files a/docs/theme/fonts/Lora.var.woff2 and /dev/null differ diff --git a/docs/theme/fonts/fonts.css b/docs/theme/fonts/fonts.css index f55cb6ee898ad7c346d7e1774323a70e1fda001f..49a3bd666476efc571f483c5170e882e7e2c436c 100644 --- a/docs/theme/fonts/fonts.css +++ b/docs/theme/fonts/fonts.css @@ -3,15 +3,37 @@ /* open-sans-300 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ @font-face { - font-family: "IA Writer Quattro S"; + font-family: "iA Writer Quattro S"; + src: url("https://cdn.zed.dev/fonts/iAWriterQuattroV.woff2") + format("woff2-variations"); + font-weight: 100 900; font-style: normal; - font-weight: 400; - src: url("iAWriterQuattroS-Regular.woff2") format("woff2"); + font-display: swap; } @font-face { - font-family: "Lora"; - src: url("Lora.var.woff2") format("woff2-variations"); + font-family: "iA Writer Quattro S"; + src: url("https://cdn.zed.dev/fonts/iAWriterQuattroV-Italic.woff2") + format("woff2-variations"); font-weight: 100 900; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Serif"; + src: url("https://cdn.zed.dev/fonts/IBMPlexSerif-Var.woff2") + format("woff2-variations"); + font-weight: 400 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Lilex"; + src: url("https://cdn.zed.dev/fonts/Lilex-Regular.woff2") + format("woff2-variations"); + font-weight: 400; font-style: normal; + font-display: swap; } diff --git a/docs/theme/fonts/iAWriterQuattroS-Regular.woff2 b/docs/theme/fonts/iAWriterQuattroS-Regular.woff2 deleted file mode 100644 index a25cdbcdd3f2127e7c2f6d0fe2832a83ae2fc6e5..0000000000000000000000000000000000000000 Binary files a/docs/theme/fonts/iAWriterQuattroS-Regular.woff2 and /dev/null differ diff --git a/docs/theme/page-toc.css b/docs/theme/page-toc.css index 6a16265976c8c9d8861c2791206464f1bcb4ceec..6f88ccc429eb6f29015c26722f2b9cce49807008 100644 --- a/docs/theme/page-toc.css +++ b/docs/theme/page-toc.css @@ -5,7 +5,7 @@ display: flex; flex-direction: column; gap: 4px; - padding: 28px 0 120px 0; + padding: 16px 0 120px 0; width: 200px; max-height: calc(100svh - 50px); overflow-x: hidden; diff --git a/docs/theme/plugins.css b/docs/theme/plugins.css index 8c9f0c438e8e1ecd43cd770183d0a6a3bbfe0a4f..ef59e97072bd2c2a6e580afca79bbe3dafa37f6b 100644 --- a/docs/theme/plugins.css +++ b/docs/theme/plugins.css @@ -1,8 +1,8 @@ kbd.keybinding { background-color: var(--keybinding-bg); - padding: 2px 4px; - border-radius: 3px; - font-family: monospace; + padding: 4px 4px 6px 4px; + border-radius: 4px; + font-family: var(--mono-font); display: inline-block; margin: 0 2px; } diff --git a/extensions/glsl/Cargo.toml b/extensions/glsl/Cargo.toml index 5d7b6ce941c14f68410ac33f825d0ee0b645d6b5..a02c93c0387424255fa32abf8fb027e2d923b809 100644 --- a/extensions/glsl/Cargo.toml +++ b/extensions/glsl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_glsl" -version = "0.2.2" +version = "0.2.3" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/glsl/extension.toml b/extensions/glsl/extension.toml index f866091b84674780e859407ebd893641a3a159ce..1fcc888ebdfc14b1fb94d136c99e2ef6b7008b94 100644 --- a/extensions/glsl/extension.toml +++ b/extensions/glsl/extension.toml @@ -1,7 +1,7 @@ id = "glsl" name = "GLSL" description = "GLSL support." -version = "0.2.2" +version = "0.2.3" schema_version = 1 authors = ["Mikayla Maki "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/glsl/languages/glsl/injections.scm b/extensions/glsl/languages/glsl/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..2f0e58eb6431515b86b6042e5828263341513e99 --- /dev/null +++ b/extensions/glsl/languages/glsl/injections.scm @@ -0,0 +1,2 @@ +((comment) @injection.content + (#set! injection.language "comment")) diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 68a524ed944b0db1fd75b9ec5ca5e0b1aa99e89f..5ca9720e25fb7cb115004d0de7c47e45d7e6252a 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.3.1" +version = "0.3.2" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index 70ebed1ca50635d9e818ce216920937a547b64c4..42985998e4dc934f9b6860ee0a5778a097d5723a 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,7 +1,7 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.3.1" +version = "0.3.2" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/proto/languages/proto/injections.scm b/extensions/proto/languages/proto/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..2f0e58eb6431515b86b6042e5828263341513e99 --- /dev/null +++ b/extensions/proto/languages/proto/injections.scm @@ -0,0 +1,2 @@ +((comment) @injection.content + (#set! injection.language "comment")) diff --git a/script/docs-suggest-publish b/script/docs-suggest-publish index 23578785159b5fd720e84d3658f7f76dddf3ada9..fc420f3fbc774df0dbd7667a5cd6dd76682e9548 100755 --- a/script/docs-suggest-publish +++ b/script/docs-suggest-publish @@ -131,14 +131,14 @@ if [[ "$DRY_RUN" == "true" ]]; then echo "Would auto-apply suggestions to docs via Droid and create a draft PR." echo "Model: $MODEL" echo "" - + # Show each suggestion file for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do echo "--- $file ---" git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || echo "(file not found)" echo "" done - + echo -e "${YELLOW}=== END DRY RUN ===${NC}" echo "" echo "Run without --dry-run to create the PR." @@ -213,7 +213,7 @@ fi FLAGGED_PRS=() FLAGS_FILE="$REPO_ROOT/crates/feature_flags/src/flags.rs" if [[ -f "$FLAGS_FILE" ]]; then - # Extract feature flag struct names (e.g. SubagentsFeatureFlag, GitGraphFeatureFlag) + # Extract feature flag struct names (e.g. SubagentsFeatureFlag) FLAG_NAMES=$(grep -oE 'pub struct \w+FeatureFlag' "$FLAGS_FILE" | awk '{print $3}') if [[ -n "$FLAG_NAMES" ]]; then FLAG_PATTERN=$(echo "$FLAG_NAMES" | tr '\n' '|' | sed 's/|$//') @@ -538,10 +538,10 @@ echo -e "${GREEN}PR created:${NC} $PR_URL" if [[ "$KEEP_QUEUE" != "true" ]]; then echo "" echo "Resetting suggestions queue..." - + git checkout --orphan "${SUGGESTIONS_BRANCH}-reset" git rm -rf . > /dev/null 2>&1 || true - + cat > README.md << 'EOF' # Documentation Suggestions Queue @@ -562,19 +562,19 @@ run `script/docs-suggest-publish` to create a documentation PR from these sugges 3. At preview release, suggestions are collected into a docs PR 4. After docs PR is created, this branch is reset EOF - + mkdir -p suggestions echo '{"suggestions":[]}' > manifest.json git add README.md suggestions manifest.json git commit -m "Reset documentation suggestions queue Previous suggestions published in: $PR_URL" - + # Force push required: replacing the orphan suggestions branch with a clean slate git push -f origin "${SUGGESTIONS_BRANCH}-reset:$SUGGESTIONS_BRANCH" git checkout "$ORIGINAL_BRANCH" git branch -D "${SUGGESTIONS_BRANCH}-reset" - + echo "Suggestions queue reset." else git checkout "$ORIGINAL_BRANCH"