diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0870a55e60057cb8440c7d911ab78aef18c04045..383ba93a8fba8173c0e8025b5730c6496fc49c66 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,11 +24,13 @@ jobs: - name: Prettier Check on /docs working-directory: ./docs run: | - pnpm dlx prettier . --check || { + pnpm dlx prettier@${PRETTIER_VERSION} . --check || { echo "To fix, run from the root of the zed repo:" - echo " cd docs && pnpm dlx prettier . --write && cd .." + echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .." false } + env: + PRETTIER_VERSION: 3.5.0 - name: Check for Typos with Typos-CLI uses: crate-ci/typos@8e6a4285bcbde632c5d79900a7779746e8b7ea3f # v1.24.6 diff --git a/.gitignore b/.gitignore index a8e2e1e8b67699be4ab98f7568886d7a19dcb276..99a6184f564ead8b08e50248e42c945b4328ca52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,35 @@ -/.direnv -.envrc -.idea -**/target +**/*.db **/cargo-target -/zed.xcworkspace -.DS_Store -/plugins/bin -/script/node_modules -/crates/theme/schemas/theme.json -/crates/collab/seed.json -/crates/zed/resources/flatpak/flatpak-cargo-sources.json -/dev.zed.Zed*.json -/assets/*licenses.* +**/target **/venv -.build *.wasm -Packages *.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.DS_Store +.blob_store +.build +.envrc +.flatpak-builder +.idea .netrc -.swiftpm -**/*.db .pytest_cache +.swiftpm +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .venv -.blob_store .vscode .wrangler -.flatpak-builder -.envrc +/.direnv +/assets/*licenses.* +/crates/collab/seed.json +/crates/theme/schemas/theme.json +/crates/zed/resources/flatpak/flatpak-cargo-sources.json +/dev.zed.Zed*.json +/plugins/bin +/script/node_modules +/zed.xcworkspace +DerivedData/ +Packages +xcuserdata/ # Don't commit any secrets to the repo. .env.secret.toml diff --git a/Cargo.lock b/Cargo.lock index cbf5db022b2d638d8973307e8d1576e7176e1b40..e90e2a23b8556e44d9bddaeb245f09e19d943cd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2942,6 +2942,28 @@ dependencies = [ "gpui", ] +[[package]] +name = "component" +version = "0.1.0" +dependencies = [ + "collections", + "gpui", + "linkme", + "once_cell", + "parking_lot", + "theme", +] + +[[package]] +name = "component_preview" +version = "0.1.0" +dependencies = [ + "component", + "gpui", + "ui", + "workspace", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -5330,6 +5352,7 @@ dependencies = [ "editor", "feature_flags", "futures 0.3.31", + "fuzzy", "git", "gpui", "language", @@ -5349,6 +5372,7 @@ dependencies = [ "util", "windows 0.58.0", "workspace", + "zed_actions", ] [[package]] @@ -7184,7 +7208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -7278,6 +7302,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "linkme" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566336154b9e58a4f055f6dd4cbab62c7dc0826ce3c0a04e63b2d2ecd784cdae" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edbe595006d355eaf9ae11db92707d4338cd2384d16866131cc1afdbdd35d8d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -8691,9 +8735,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "oo7" @@ -9000,7 +9044,10 @@ dependencies = [ name = "panel" version = "0.1.0" dependencies = [ + "editor", "gpui", + "settings", + "theme", "ui", "workspace", ] @@ -14318,8 +14365,10 @@ name = "ui" version = "0.1.0" dependencies = [ "chrono", + "component", "gpui", "itertools 0.14.0", + "linkme", "menu", "serde", "settings", @@ -14347,6 +14396,7 @@ name = "ui_macros" version = "0.1.0" dependencies = [ "convert_case 0.7.1", + "linkme", "proc-macro2", "quote", "syn 1.0.109", @@ -14616,22 +14666,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vcs_menu" -version = "0.1.0" -dependencies = [ - "anyhow", - "fuzzy", - "git", - "gpui", - "picker", - "project", - "ui", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "version-compare" version = "0.2.0" @@ -16134,6 +16168,7 @@ dependencies = [ "client", "clock", "collections", + "component", "db", "derive_more", "env_logger 0.11.6", @@ -16568,6 +16603,7 @@ dependencies = [ "collections", "command_palette", "command_palette_hooks", + "component_preview", "copilot", "db", "diagnostics", @@ -16657,7 +16693,6 @@ dependencies = [ "urlencoding", "util", "uuid", - "vcs_menu", "vim", "vim_mode_setting", "welcome", @@ -16766,12 +16801,13 @@ dependencies = [ [[package]] name = "zed_llm_client" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea4d8ead1e1158e5ebdd6735df25973781da70de5c8008e3a13595865ca4f31" +checksum = "614669bead4741b2fc352ae1967318be16949cf46f59013e548c6dbfdfc01252" dependencies = [ "serde", "serde_json", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 217cdd9d1f27072e1188de71b7063ddbb9aaa176..147d2c32e138762681b81dee2486eae3fce5a603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ members = [ "crates/collections", "crates/command_palette", "crates/command_palette_hooks", + "crates/component", + "crates/component_preview", "crates/context_server", "crates/context_server_settings", "crates/copilot", @@ -147,7 +149,6 @@ members = [ "crates/ui_macros", "crates/util", "crates/util_macros", - "crates/vcs_menu", "crates/vim", "crates/vim_mode_setting", "crates/welcome", @@ -227,6 +228,8 @@ collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } +component = { path = "crates/component" } +component_preview = { path = "crates/component_preview" } context_server = { path = "crates/context_server" } context_server_settings = { path = "crates/context_server_settings" } copilot = { path = "crates/copilot" } @@ -346,7 +349,6 @@ ui_input = { path = "crates/ui_input" } ui_macros = { path = "crates/ui_macros" } util = { path = "crates/util" } util_macros = { path = "crates/util_macros" } -vcs_menu = { path = "crates/vcs_menu" } vim = { path = "crates/vim" } vim_mode_setting = { path = "crates/vim_mode_setting" } welcome = { path = "crates/welcome" } @@ -428,6 +430,7 @@ jupyter-websocket-client = { version = "0.9.0" } libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" +linkme = "0.3.31" livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "811ceae29fabee455f110c56cd66b3f49a7e5003", features = [ "dispatcher", "services-dispatcher", @@ -561,7 +564,7 @@ wasmtime = { version = "24", default-features = false, features = [ wasmtime-wasi = "24" which = "6.0.0" wit-component = "0.201" -zed_llm_client = "0.2" +zed_llm_client = "0.4" zstd = "0.11" metal = "0.31" @@ -676,7 +679,6 @@ telemetry_events = { codegen-units = 1 } theme_selector = { codegen-units = 1 } time_format = { codegen-units = 1 } ui_input = { codegen-units = 1 } -vcs_menu = { codegen-units = 1 } zed_actions = { codegen-units = 1 } [profile.release] diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index f36ef2737ff4d246b8b73160e93a88a9902787ca..ffed3d680dd6a5686c6963c4074e7f2bff382fd7 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -150,8 +150,19 @@ "postcss": "css", "ppt": "document", "pptx": "document", + "prettier.config.cjs": "prettier", + "prettier.config.js": "prettier", + "prettier.config.mjs": "prettier", "prettierignore": "prettier", "prettierrc": "prettier", + "prettierrc.cjs": "prettier", + "prettierrc.js": "prettier", + "prettierrc.json": "prettier", + "prettierrc.json5": "prettier", + "prettierrc.mjs": "prettier", + "prettierrc.toml": "prettier", + "prettierrc.yaml": "prettier", + "prettierrc.yml": "prettier", "prisma": "prisma", "profile": "terminal", "ps1": "terminal", diff --git a/assets/icons/lock_outlined.svg b/assets/icons/lock_outlined.svg new file mode 100644 index 0000000000000000000000000000000000000000..0bfd2fdc82ad6cfd21e9fd2c901a7604fb6c0ba9 --- /dev/null +++ b/assets/icons/lock_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 42c879a534722699147a59d768791b07a8f66b5d..48eebbeaef5af04bc861bcf95616a9cdec30cfde 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -122,7 +122,8 @@ "ctrl-i": "editor::ShowSignatureHelp", "alt-g b": "editor::ToggleGitBlame", "menu": "editor::OpenContextMenu", - "shift-f10": "editor::OpenContextMenu" + "shift-f10": "editor::OpenContextMenu", + "ctrl-shift-e": "editor::ToggleEditPrediction" } }, { @@ -535,8 +536,7 @@ { "bindings": { "ctrl-alt-shift-f": "workspace::FollowNextCollaborator", - "ctrl-alt-i": "zed::DebugElements", - "ctrl-:": "editor::ToggleInlayHints" + "ctrl-alt-i": "zed::DebugElements" } }, { @@ -554,7 +554,8 @@ "ctrl-shift-e": "pane::RevealInProjectPanel", "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPrevHunk", - "ctrl-enter": "assistant::InlineAssist" + "ctrl-enter": "assistant::InlineAssist", + "ctrl-:": "editor::ToggleInlayHints" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 7f852ee4f76797dad7e56fb7d65278b45646e6b5..5fa14b940c592f19f708b198ebe47dc41d917174 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -39,8 +39,8 @@ "cmd-m": "zed::Minimize", "fn-f": "zed::ToggleFullScreen", "ctrl-cmd-f": "zed::ToggleFullScreen", - "ctrl-shift-z": "zeta::RateCompletions", - "ctrl-shift-i": "edit_prediction::ToggleMenu" + "ctrl-cmd-z": "zeta::RateCompletions", + "ctrl-cmd-i": "edit_prediction::ToggleMenu" } }, { @@ -132,7 +132,8 @@ "cmd-alt-g b": "editor::ToggleGitBlame", "cmd-i": "editor::ShowSignatureHelp", "ctrl-f12": "editor::GoToDeclaration", - "alt-ctrl-f12": "editor::GoToDeclarationSplit" + "alt-ctrl-f12": "editor::GoToDeclarationSplit", + "ctrl-cmd-e": "editor::ToggleEditPrediction" } }, { @@ -619,8 +620,7 @@ "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator", // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", - "cmd-alt-i": "zed::DebugElements", - "ctrl-:": "editor::ToggleInlayHints" + "cmd-alt-i": "zed::DebugElements" } }, { @@ -633,7 +633,8 @@ "cmd-shift-e": "pane::RevealInProjectPanel", "cmd-f8": "editor::GoToHunk", "cmd-shift-f8": "editor::GoToPrevHunk", - "ctrl-enter": "assistant::InlineAssist" + "ctrl-enter": "assistant::InlineAssist", + "ctrl-:": "editor::ToggleInlayHints" } }, { @@ -737,6 +738,7 @@ "context": "GitPanel > Editor", "use_key_equivalents": true, "bindings": { + "enter": "editor::Newline", "cmd-enter": "git::Commit", "tab": "git_panel::FocusChanges", "shift-tab": "git_panel::FocusChanges", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index af1822d706aee72f84e73981758c5c7b2c8328e0..aa3e44892c18a30878091cbe9e59c9b616879200 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -381,6 +381,12 @@ "ctrl-q": ["vim::PushLiteral", {}] } }, + { + "context": "Editor && vim_mode == waiting && (vim_operator == ys || vim_operator == cs)", + "bindings": { + "escape": "vim::SwitchToNormalMode" + } + }, { "context": "vim_mode == operator", "bindings": { diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 4e26d646ddd8101e5785ca796550f09fdfd72ed3..1cac0db14b9336deb2a19c0e2dc33f162d075009 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -81,7 +81,7 @@ "terminal.ansi.bright_green": "#4d6140ff", "terminal.ansi.dim_green": "#d1e0bfff", "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#786441ff", + "terminal.ansi.bright_yellow": "#e5c07bff", "terminal.ansi.dim_yellow": "#f1dfc1ff", "terminal.ansi.blue": "#74ade8ff", "terminal.ansi.bright_blue": "#385378ff", @@ -457,7 +457,7 @@ "terminal.ansi.bright_green": "#b2cfa9ff", "terminal.ansi.dim_green": "#354d2eff", "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#f1dfc1ff", + "terminal.ansi.bright_yellow": "#826221ff", "terminal.ansi.dim_yellow": "#786441ff", "terminal.ansi.blue": "#5c78e2ff", "terminal.ansi.bright_blue": "#b5baf2ff", diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index b3b11fa9c737efab43428e99e613ce94ed79824a..d5e164358974cda9754d590873a6de098826e1a8 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -250,10 +250,10 @@ impl AssistantPanel { ) .child( PopoverMenu::new("assistant-panel-popover-menu") - .trigger( + .trigger_with_tooltip( IconButton::new("menu", IconName::EllipsisVertical) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Toggle Assistant Menu")), + .icon_size(IconSize::Small), + Tooltip::text("Toggle Assistant Menu"), ) .menu(move |window, cx| { let zoom_label = if _pane.read(cx).is_zoomed() { diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index 53f142c029fb08361cf69c9471a41bbb7e3fb2cb..286440f9896a7f22e2cafed614861f09ee64e921 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -1595,22 +1595,22 @@ impl Render for PromptEditor { IconButton::new("context", IconName::SettingsAlt) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(move |window, cx| { - Tooltip::with_meta( - format!( - "Using {}", - LanguageModelRegistry::read_global(cx) - .active_model() - .map(|model| model.name().0) - .unwrap_or_else(|| "No model selected".into()), - ), - None, - "Change Model", - window, - cx, - ) - }), + .icon_color(Color::Muted), + move |window, cx| { + Tooltip::with_meta( + format!( + "Using {}", + LanguageModelRegistry::read_global(cx) + .active_model() + .map(|model| model.name().0) + .unwrap_or_else(|| "No model selected".into()), + ), + None, + "Change Model", + window, + cx, + ) + }, )) .map(|el| { let CodegenStatus::Error(error) = self.codegen.read(cx).status(cx) else { diff --git a/crates/assistant/src/terminal_inline_assistant.rs b/crates/assistant/src/terminal_inline_assistant.rs index 4547ea8e679e444b6503f0edada306c8a2ea6597..a7f1d1966769306152c0eb7971535edbf68b42bb 100644 --- a/crates/assistant/src/terminal_inline_assistant.rs +++ b/crates/assistant/src/terminal_inline_assistant.rs @@ -646,22 +646,22 @@ impl Render for PromptEditor { IconButton::new("context", IconName::SettingsAlt) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(move |window, cx| { - Tooltip::with_meta( - format!( - "Using {}", - LanguageModelRegistry::read_global(cx) - .active_model() - .map(|model| model.name().0) - .unwrap_or_else(|| "No model selected".into()), - ), - None, - "Change Model", - window, - cx, - ) - }), + .icon_color(Color::Muted), + move |window, cx| { + Tooltip::with_meta( + format!( + "Using {}", + LanguageModelRegistry::read_global(cx) + .active_model() + .map(|model| model.name().0) + .unwrap_or_else(|| "No model selected".into()), + ), + None, + "Change Model", + window, + cx, + ) + }, )) .children( if let CodegenStatus::Error(error) = &self.codegen.read(cx).status { diff --git a/crates/assistant2/src/assistant_model_selector.rs b/crates/assistant2/src/assistant_model_selector.rs index cca0454bf17ad74c9f87887d995b1ceeccf8ac1b..0308757e59298d9a86f27e1efdd30fdf7d654ae8 100644 --- a/crates/assistant2/src/assistant_model_selector.rs +++ b/crates/assistant2/src/assistant_model_selector.rs @@ -74,16 +74,16 @@ impl Render for AssistantModelSelector { .color(Color::Muted) .size(IconSize::XSmall), ), + ), + move |window, cx| { + Tooltip::for_action_in( + "Change Model", + &ToggleModelSelector, + &focus_handle, + window, + cx, ) - .tooltip(move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) - }), + }, ) .with_handle(self.menu_handle.clone()) } diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 45f3d15a81484b6aa7300257cde9e01d7051fa26..bfdeeb545bb939bc93f73cc5891e86c9208b6e45 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -660,11 +660,11 @@ impl AssistantPanel { .gap(DynamicSpacing::Base02.rems(cx)) .child( PopoverMenu::new("assistant-toolbar-new-popover-menu") - .trigger( + .trigger_with_tooltip( IconButton::new("new", IconName::Plus) .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("New…")), + .style(ButtonStyle::Subtle), + Tooltip::text("New…"), ) .anchor(Corner::TopRight) .with_handle(self.new_item_context_menu_handle.clone()) @@ -677,11 +677,11 @@ impl AssistantPanel { ) .child( PopoverMenu::new("assistant-toolbar-history-popover-menu") - .trigger( + .trigger_with_tooltip( IconButton::new("open-history", IconName::HistoryRerun) .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("History…")), + .style(ButtonStyle::Subtle), + Tooltip::text("History…"), ) .anchor(Corner::TopRight) .with_handle(self.open_history_context_menu_handle.clone()) diff --git a/crates/assistant2/src/context_strip.rs b/crates/assistant2/src/context_strip.rs index d7b1503713a9e63b21d9b90d75e65bf39e0360b0..317eaad8a1547d0b0de2a2c0b0b833966ceebb39 100644 --- a/crates/assistant2/src/context_strip.rs +++ b/crates/assistant2/src/context_strip.rs @@ -411,22 +411,22 @@ impl Render for ContextStrip { Some(context_picker.clone()) }) - .trigger( + .trigger_with_tooltip( IconButton::new("add-context", IconName::Plus) .icon_size(IconSize::Small) - .style(ui::ButtonStyle::Filled) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Add Context", - &ToggleContextPicker, - &focus_handle, - window, - cx, - ) - } - }), + .style(ui::ButtonStyle::Filled), + { + let focus_handle = focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Add Context", + &ToggleContextPicker, + &focus_handle, + window, + cx, + ) + } + }, ) .attach(gpui::Corner::TopLeft) .anchor(gpui::Corner::BottomLeft) diff --git a/crates/assistant_context_editor/src/context_editor.rs b/crates/assistant_context_editor/src/context_editor.rs index 290cff13fae047039d32ab6987f2d4e2ab69e002..fcfce741c1e8125fd241b99c5791565e3262c943 100644 --- a/crates/assistant_context_editor/src/context_editor.rs +++ b/crates/assistant_context_editor/src/context_editor.rs @@ -2359,8 +2359,8 @@ impl ContextEditor { .icon(IconName::Plus) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .tooltip(Tooltip::text("Type / to insert via keyboard")), + .icon_position(IconPosition::Start), + Tooltip::text("Type / to insert via keyboard"), ) } @@ -3323,10 +3323,10 @@ impl Render for ContextEditorToolbarItem { .color(Color::Muted) .size(IconSize::XSmall), ), - ) - .tooltip(move |window, cx| { - Tooltip::for_action("Change Model", &ToggleModelSelector, window, cx) - }), + ), + move |window, cx| { + Tooltip::for_action("Change Model", &ToggleModelSelector, window, cx) + }, ) .with_handle(self.language_model_selector_menu_handle.clone()), ) diff --git a/crates/assistant_context_editor/src/slash_command_picker.rs b/crates/assistant_context_editor/src/slash_command_picker.rs index 373e5f09ddcb7f57b21d1d1d03a0277d84ae24a0..3bdc3160300eca2ec71f349c9905145bb9c5f49f 100644 --- a/crates/assistant_context_editor/src/slash_command_picker.rs +++ b/crates/assistant_context_editor/src/slash_command_picker.rs @@ -1,17 +1,22 @@ use std::sync::Arc; use assistant_slash_command::SlashCommandWorkingSet; -use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakEntity}; +use gpui::{AnyElement, AnyView, DismissEvent, SharedString, Task, WeakEntity}; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip}; use crate::context_editor::ContextEditor; #[derive(IntoElement)] -pub(super) struct SlashCommandSelector { +pub(super) struct SlashCommandSelector +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ working_set: Arc, active_context_editor: WeakEntity, trigger: T, + tooltip: TT, } #[derive(Clone)] @@ -48,16 +53,22 @@ pub(crate) struct SlashCommandDelegate { selected_index: usize, } -impl SlashCommandSelector { +impl SlashCommandSelector +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ pub(crate) fn new( working_set: Arc, active_context_editor: WeakEntity, trigger: T, + tooltip: TT, ) -> Self { SlashCommandSelector { working_set, active_context_editor, trigger, + tooltip, } } } @@ -241,7 +252,11 @@ impl PickerDelegate for SlashCommandDelegate { } } -impl RenderOnce for SlashCommandSelector { +impl RenderOnce for SlashCommandSelector +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let all_models = self .working_set @@ -322,7 +337,7 @@ impl RenderOnce for SlashCommandSelector { .ok(); PopoverMenu::new("model-switcher") .menu(move |_window, _cx| Some(picker_view.clone())) - .trigger(self.trigger) + .trigger_with_tooltip(self.trigger, self.tooltip) .attach(gpui::Corner::TopLeft) .anchor(gpui::Corner::BottomLeft) .offset(gpui::Point { diff --git a/crates/component/Cargo.toml b/crates/component/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..33f951ff9520b2149c21c1494194414ce904881a --- /dev/null +++ b/crates/component/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "component" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/component.rs" + +[dependencies] +collections.workspace = true +gpui.workspace = true +linkme.workspace = true +once_cell = "1.20.3" +parking_lot.workspace = true +theme.workspace = true + +[features] +default = [] diff --git a/crates/vcs_menu/LICENSE-GPL b/crates/component/LICENSE-GPL similarity index 100% rename from crates/vcs_menu/LICENSE-GPL rename to crates/component/LICENSE-GPL diff --git a/crates/component/src/component.rs b/crates/component/src/component.rs new file mode 100644 index 0000000000000000000000000000000000000000..e4a2ae7921f7cee42d99c11517b23e67aa6d3653 --- /dev/null +++ b/crates/component/src/component.rs @@ -0,0 +1,305 @@ +use std::ops::{Deref, DerefMut}; + +use collections::HashMap; +use gpui::{div, prelude::*, AnyElement, App, IntoElement, RenderOnce, SharedString, Window}; +use linkme::distributed_slice; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use theme::ActiveTheme; + +pub trait Component { + fn scope() -> Option<&'static str>; + fn name() -> &'static str { + std::any::type_name::() + } + fn description() -> Option<&'static str> { + None + } +} + +pub trait ComponentPreview: Component { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement; +} + +#[distributed_slice] +pub static __ALL_COMPONENTS: [fn()] = [..]; + +#[distributed_slice] +pub static __ALL_PREVIEWS: [fn()] = [..]; + +pub static COMPONENT_DATA: Lazy> = + Lazy::new(|| RwLock::new(ComponentRegistry::new())); + +pub struct ComponentRegistry { + components: Vec<(Option<&'static str>, &'static str, Option<&'static str>)>, + previews: HashMap<&'static str, fn(&mut Window, &App) -> AnyElement>, +} + +impl ComponentRegistry { + fn new() -> Self { + ComponentRegistry { + components: Vec::new(), + previews: HashMap::default(), + } + } +} + +pub fn init() { + let component_fns: Vec<_> = __ALL_COMPONENTS.iter().cloned().collect(); + let preview_fns: Vec<_> = __ALL_PREVIEWS.iter().cloned().collect(); + + for f in component_fns { + f(); + } + for f in preview_fns { + f(); + } +} + +pub fn register_component() { + let component_data = (T::scope(), T::name(), T::description()); + COMPONENT_DATA.write().components.push(component_data); +} + +pub fn register_preview() { + let preview_data = (T::name(), T::preview as fn(&mut Window, &App) -> AnyElement); + COMPONENT_DATA + .write() + .previews + .insert(preview_data.0, preview_data.1); +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ComponentId(pub &'static str); + +#[derive(Clone)] +pub struct ComponentMetadata { + name: SharedString, + scope: Option, + description: Option, + preview: Option AnyElement>, +} + +impl ComponentMetadata { + pub fn name(&self) -> SharedString { + self.name.clone() + } + + pub fn scope(&self) -> Option { + self.scope.clone() + } + + pub fn description(&self) -> Option { + self.description.clone() + } + + pub fn preview(&self) -> Option AnyElement> { + self.preview + } +} + +pub struct AllComponents(pub HashMap); + +impl AllComponents { + pub fn new() -> Self { + AllComponents(HashMap::default()) + } + + /// Returns all components with previews + pub fn all_previews(&self) -> Vec<&ComponentMetadata> { + self.0.values().filter(|c| c.preview.is_some()).collect() + } + + /// Returns all components with previews sorted by name + pub fn all_previews_sorted(&self) -> Vec { + let mut previews: Vec = + self.all_previews().into_iter().cloned().collect(); + previews.sort_by_key(|a| a.name()); + previews + } + + /// Returns all components + pub fn all(&self) -> Vec<&ComponentMetadata> { + self.0.values().collect() + } + + /// Returns all components sorted by name + pub fn all_sorted(&self) -> Vec { + let mut components: Vec = self.all().into_iter().cloned().collect(); + components.sort_by_key(|a| a.name()); + components + } +} + +impl Deref for AllComponents { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for AllComponents { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +pub fn components() -> AllComponents { + let data = COMPONENT_DATA.read(); + let mut all_components = AllComponents::new(); + + for &(scope, name, description) in &data.components { + let scope = scope.map(Into::into); + let preview = data.previews.get(name).cloned(); + all_components.insert( + ComponentId(name), + ComponentMetadata { + name: name.into(), + scope, + description: description.map(Into::into), + preview, + }, + ); + } + + all_components +} + +/// Which side of the preview to show labels on +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExampleLabelSide { + /// Left side + Left, + /// Right side + Right, + #[default] + /// Top side + Top, + /// Bottom side + Bottom, +} + +/// A single example of a component. +#[derive(IntoElement)] +pub struct ComponentExample { + variant_name: SharedString, + element: AnyElement, + label_side: ExampleLabelSide, + grow: bool, +} + +impl RenderOnce for ComponentExample { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let base = div().flex(); + + let base = match self.label_side { + ExampleLabelSide::Right => base.flex_row(), + ExampleLabelSide::Left => base.flex_row_reverse(), + ExampleLabelSide::Bottom => base.flex_col(), + ExampleLabelSide::Top => base.flex_col_reverse(), + }; + + base.gap_1() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .when(self.grow, |this| this.flex_1()) + .child(self.element) + .child(self.variant_name) + .into_any_element() + } +} + +impl ComponentExample { + /// Create a new example with the given variant name and example value. + pub fn new(variant_name: impl Into, element: AnyElement) -> Self { + Self { + variant_name: variant_name.into(), + element, + label_side: ExampleLabelSide::default(), + grow: false, + } + } + + /// Set the example to grow to fill the available horizontal space. + pub fn grow(mut self) -> Self { + self.grow = true; + self + } +} + +/// A group of component examples. +#[derive(IntoElement)] +pub struct ComponentExampleGroup { + pub title: Option, + pub examples: Vec, + pub grow: bool, +} + +impl RenderOnce for ComponentExampleGroup { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .flex_col() + .text_sm() + .text_color(cx.theme().colors().text_muted) + .when(self.grow, |this| this.w_full().flex_1()) + .when_some(self.title, |this, title| this.gap_4().child(title)) + .child( + div() + .flex() + .items_start() + .w_full() + .gap_6() + .children(self.examples) + .into_any_element(), + ) + .into_any_element() + } +} + +impl ComponentExampleGroup { + /// Create a new group of examples with the given title. + pub fn new(examples: Vec) -> Self { + Self { + title: None, + examples, + grow: false, + } + } + + /// Create a new group of examples with the given title. + pub fn with_title(title: impl Into, examples: Vec) -> Self { + Self { + title: Some(title.into()), + examples, + grow: false, + } + } + + /// Set the group to grow to fill the available horizontal space. + pub fn grow(mut self) -> Self { + self.grow = true; + self + } +} + +/// Create a single example +pub fn single_example( + variant_name: impl Into, + example: AnyElement, +) -> ComponentExample { + ComponentExample::new(variant_name, example) +} + +/// Create a group of examples without a title +pub fn example_group(examples: Vec) -> ComponentExampleGroup { + ComponentExampleGroup::new(examples) +} + +/// Create a group of examples with a title +pub fn example_group_with_title( + title: impl Into, + examples: Vec, +) -> ComponentExampleGroup { + ComponentExampleGroup::with_title(title, examples) +} diff --git a/crates/vcs_menu/Cargo.toml b/crates/component_preview/Cargo.toml similarity index 53% rename from crates/vcs_menu/Cargo.toml rename to crates/component_preview/Cargo.toml index 1e9826d53d3e988611cea980b37954bd399413ce..d909991a1893912ecd777d4503d983605ac85f05 100644 --- a/crates/vcs_menu/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vcs_menu" +name = "component_preview" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -8,14 +8,14 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +path = "src/component_preview.rs" + +[features] +default = [] + [dependencies] -anyhow.workspace = true -fuzzy.workspace = true -git.workspace = true +component.workspace = true gpui.workspace = true -picker.workspace = true -project.workspace = true ui.workspace = true -util.workspace = true workspace.workspace = true -zed_actions.workspace = true diff --git a/crates/component_preview/LICENSE-GPL b/crates/component_preview/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/component_preview/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..84e00f751c76d656ec977355f8ed2c4d5aa350bb --- /dev/null +++ b/crates/component_preview/src/component_preview.rs @@ -0,0 +1,178 @@ +//! # Component Preview +//! +//! A view for exploring Zed components. + +use component::{components, ComponentMetadata}; +use gpui::{prelude::*, App, EventEmitter, FocusHandle, Focusable, Window}; +use ui::prelude::*; + +use workspace::{item::ItemEvent, Item, Workspace, WorkspaceId}; + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _cx| { + workspace.register_action( + |workspace, _: &workspace::OpenComponentPreview, window, cx| { + let component_preview = cx.new(ComponentPreview::new); + workspace.add_item_to_active_pane( + Box::new(component_preview), + None, + true, + window, + cx, + ) + }, + ); + }) + .detach(); +} + +struct ComponentPreview { + focus_handle: FocusHandle, +} + +impl ComponentPreview { + pub fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } + + fn render_sidebar(&self, _window: &Window, _cx: &Context) -> impl IntoElement { + let components = components().all_sorted(); + let sorted_components = components.clone(); + + v_flex().gap_px().p_1().children( + sorted_components + .into_iter() + .map(|component| self.render_sidebar_entry(&component, _cx)), + ) + } + + fn render_sidebar_entry( + &self, + component: &ComponentMetadata, + _cx: &Context, + ) -> impl IntoElement { + h_flex() + .w_40() + .px_1p5() + .py_1() + .child(component.name().clone()) + } + + fn render_preview( + &self, + component: &ComponentMetadata, + window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + let name = component.name(); + let scope = component.scope(); + + let description = component.description(); + + v_group() + .w_full() + .gap_4() + .p_8() + .rounded_md() + .child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .text_xl() + .child(div().child(name)) + .when_some(scope, |this, scope| { + this.child(div().opacity(0.5).child(format!("({})", scope))) + }), + ) + .when_some(description, |this, description| { + this.child( + div() + .text_ui_sm(cx) + .text_color(cx.theme().colors().text_muted) + .max_w(px(600.0)) + .child(description), + ) + }), + ) + .when_some(component.preview(), |this, preview| { + this.child(preview(window, cx)) + }) + .into_any_element() + } + + fn render_previews(&self, window: &mut Window, cx: &Context) -> impl IntoElement { + v_flex() + .id("component-previews") + .size_full() + .overflow_y_scroll() + .p_4() + .gap_2() + .children( + components() + .all_previews_sorted() + .iter() + .map(|component| self.render_preview(component, window, cx)), + ) + } +} + +impl Render for ComponentPreview { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + h_flex() + .id("component-preview") + .key_context("ComponentPreview") + .items_start() + .overflow_hidden() + .size_full() + .max_h_full() + .track_focus(&self.focus_handle) + .px_2() + .bg(cx.theme().colors().editor_background) + .child(self.render_sidebar(window, cx)) + .child(self.render_previews(window, cx)) + } +} + +impl EventEmitter for ComponentPreview {} + +impl Focusable for ComponentPreview { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for ComponentPreview { + type Event = ItemEvent; + + fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option { + Some("Component Preview".into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn show_toolbar(&self) -> bool { + false + } + + fn clone_on_split( + &self, + _workspace_id: Option, + _window: &mut Window, + cx: &mut Context, + ) -> Option> + where + Self: Sized, + { + Some(cx.new(Self::new)) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { + f(*event) + } +} diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index 0e494056ec4521e1fe24e31e68258812d501ba38..e6757e9d7fa4615af101cf14140442f3c4e61ed9 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_completion_provider.rs @@ -242,6 +242,7 @@ impl EditPredictionProvider for CopilotCompletionProvider { } else { let position = cursor_position.bias_right(buffer); Some(InlineCompletion { + id: None, edits: vec![(position..position, completion_text.into())], edit_preview: None, }) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b5ef81bfbabaf1019a31a4aa2e2e41cb37617455..410509139571e74e9caf72d6f156df915af768a0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -490,6 +490,7 @@ enum InlineCompletion { struct InlineCompletionState { inlay_ids: Vec, completion: InlineCompletion, + completion_id: Option, invalidation_range: Range, } @@ -4893,7 +4894,11 @@ impl Editor { return; }; - self.report_inline_completion_event(true, cx); + self.report_inline_completion_event( + active_inline_completion.completion_id.clone(), + true, + cx, + ); match &active_inline_completion.completion { InlineCompletion::Move { target, .. } => { @@ -4942,7 +4947,11 @@ impl Editor { return; } - self.report_inline_completion_event(true, cx); + self.report_inline_completion_event( + active_inline_completion.completion_id.clone(), + true, + cx, + ); match &active_inline_completion.completion { InlineCompletion::Move { target, .. } => { @@ -5000,7 +5009,12 @@ impl Editor { cx: &mut Context, ) -> bool { if should_report_inline_completion_event { - self.report_inline_completion_event(false, cx); + let completion_id = self + .active_inline_completion + .as_ref() + .and_then(|active_completion| active_completion.completion_id.clone()); + + self.report_inline_completion_event(completion_id, false, cx); } if let Some(provider) = self.edit_prediction_provider() { @@ -5010,7 +5024,7 @@ impl Editor { self.take_active_inline_completion(cx) } - fn report_inline_completion_event(&self, accepted: bool, cx: &App) { + fn report_inline_completion_event(&self, id: Option, accepted: bool, cx: &App) { let Some(provider) = self.edit_prediction_provider() else { return; }; @@ -5035,6 +5049,7 @@ impl Editor { telemetry::event!( event_type, provider = provider.name(), + prediction_id = id, suggestion_accepted = accepted, file_extension = extension, ); @@ -5250,6 +5265,7 @@ impl Editor { self.active_inline_completion = Some(InlineCompletionState { inlay_ids, completion, + completion_id: inline_completion.id, invalidation_range, }); @@ -10283,26 +10299,14 @@ impl Editor { if entry.diagnostic.is_primary && entry.diagnostic.severity <= DiagnosticSeverity::WARNING && entry.range.start != entry.range.end + // if we match with the active diagnostic, skip it + && Some(entry.diagnostic.group_id) + != self.active_diagnostics.as_ref().map(|d| d.group_id) { - let entry_group = entry.diagnostic.group_id; - let in_next_group = self.active_diagnostics.as_ref().map_or( - true, - |active| match direction { - Direction::Prev => { - entry_group != active.group_id - && (active.group_id == 0 || entry_group < active.group_id) - } - Direction::Next => { - entry_group != active.group_id - && (entry_group == 0 || entry_group > active.group_id) - } - }, - ); - if in_next_group { - return Some((entry.range, entry.diagnostic.group_id)); - } + Some((entry.range, entry.diagnostic.group_id)) + } else { + None } - None }); if let Some((primary_range, group_id)) = group { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 5247c629c06919ce503c02545193de7143e7cafc..68bd0514dd9bac55c7032a25d46067dd191b83ba 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -10653,176 +10653,6 @@ async fn go_to_prev_overlapping_diagnostic( "}); } -#[gpui::test] -async fn cycle_through_same_place_diagnostics( - executor: BackgroundExecutor, - cx: &mut gpui::TestAppContext, -) { - init_test(cx, |_| {}); - - let mut cx = EditorTestContext::new(cx).await; - let lsp_store = - cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store()); - - cx.set_state(indoc! {" - ˇfn func(abc def: i32) -> u32 { - } - "}); - - cx.update(|_, cx| { - lsp_store.update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), - version: None, - diagnostics: vec![ - lsp::Diagnostic { - range: lsp::Range::new( - lsp::Position::new(0, 11), - lsp::Position::new(0, 12), - ), - severity: Some(lsp::DiagnosticSeverity::ERROR), - ..Default::default() - }, - lsp::Diagnostic { - range: lsp::Range::new( - lsp::Position::new(0, 12), - lsp::Position::new(0, 15), - ), - severity: Some(lsp::DiagnosticSeverity::ERROR), - ..Default::default() - }, - lsp::Diagnostic { - range: lsp::Range::new( - lsp::Position::new(0, 12), - lsp::Position::new(0, 15), - ), - severity: Some(lsp::DiagnosticSeverity::ERROR), - ..Default::default() - }, - lsp::Diagnostic { - range: lsp::Range::new( - lsp::Position::new(0, 25), - lsp::Position::new(0, 28), - ), - severity: Some(lsp::DiagnosticSeverity::ERROR), - ..Default::default() - }, - ], - }, - &[], - cx, - ) - .unwrap() - }); - }); - executor.run_until_parked(); - - //// Backward - - // Fourth diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc def: i32) -> ˇu32 { - } - "}); - - // Third diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc ˇdef: i32) -> u32 { - } - "}); - - // Second diagnostic, same place - cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc ˇdef: i32) -> u32 { - } - "}); - - // First diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abcˇ def: i32) -> u32 { - } - "}); - - // Wrapped over, fourth diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc def: i32) -> ˇu32 { - } - "}); - - cx.update_editor(|editor, window, cx| { - editor.move_to_beginning(&MoveToBeginning, window, cx); - }); - cx.assert_editor_state(indoc! {" - ˇfn func(abc def: i32) -> u32 { - } - "}); - - //// Forward - - // First diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abcˇ def: i32) -> u32 { - } - "}); - - // Second diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc ˇdef: i32) -> u32 { - } - "}); - - // Third diagnostic, same place - cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc ˇdef: i32) -> u32 { - } - "}); - - // Fourth diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abc def: i32) -> ˇu32 { - } - "}); - - // Wrapped around, first diagnostic - cx.update_editor(|editor, window, cx| { - editor.go_to_diagnostic(&GoToDiagnostic, window, cx); - }); - cx.assert_editor_state(indoc! {" - fn func(abcˇ def: i32) -> u32 { - } - "}); -} - #[gpui::test] async fn test_diagnostics_with_links(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 04bcf722625bfb40016f4d7b16b0b246f97c877e..fe829e2ad6fb11a8af93f9c5be1f98658cd8eef0 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5800,6 +5800,9 @@ fn inline_completion_accept_indicator( .child(accept_keystroke.key.clone()); let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; + let accent_color = cx.theme().colors().text_accent; + let editor_bg_color = cx.theme().colors().editor_background; + let bg_color = editor_bg_color.blend(accent_color.opacity(0.2)); Some( h_flex() @@ -5807,7 +5810,7 @@ fn inline_completion_accept_indicator( .pl_1() .pr(padding_right) .gap_1() - .bg(cx.theme().colors().text_accent.opacity(0.15)) + .bg(bg_color) .border_1() .border_color(cx.theme().colors().text_accent.opacity(0.8)) .rounded_md() diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index 8bed3e2ccb8054427e35db0ba264c23152cafcef..03dac81d653be336694e04e387d5a6c7cbadbd61 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -763,7 +763,7 @@ impl Editor { this.child({ let focus = editor.focus_handle(cx); PopoverMenu::new("hunk-controls-dropdown") - .trigger( + .trigger_with_tooltip( IconButton::new( "toggle_editor_selections_icon", IconName::EllipsisVertical, @@ -774,19 +774,8 @@ impl Editor { .toggle_state( hunk_controls_menu_handle .is_deployed(), - ) - .when( - !hunk_controls_menu_handle - .is_deployed(), - |this| { - this.tooltip(|_, cx| { - Tooltip::simple( - "Hunk Controls", - cx, - ) - }) - }, ), + Tooltip::simple("Hunk Controls", cx), ) .anchor(Corner::TopRight) .with_handle(hunk_controls_menu_handle) diff --git a/crates/editor/src/inline_completion_tests.rs b/crates/editor/src/inline_completion_tests.rs index 258a8780944052a1c398fba33cce843cc585de6d..c74de1fc9329a1984bc4c44b35815f13974e167b 100644 --- a/crates/editor/src/inline_completion_tests.rs +++ b/crates/editor/src/inline_completion_tests.rs @@ -333,6 +333,7 @@ fn propose_edits( cx.update(|_, cx| { provider.update(cx, |provider, _| { provider.set_inline_completion(Some(inline_completion::InlineCompletion { + id: None, edits: edits.collect(), edit_preview: None, })) diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index ad4dbdf9905e40e7667738c591a3ae6b47bc1664..a30792fe1051b2a16f9de3d04297756eefe00cfd 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -20,6 +20,7 @@ diff.workspace = true editor.workspace = true feature_flags.workspace = true futures.workspace = true +fuzzy.workspace = true git.workspace = true gpui.workspace = true language.workspace = true @@ -38,6 +39,7 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true +zed_actions.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/vcs_menu/src/lib.rs b/crates/git_ui/src/branch_picker.rs similarity index 77% rename from crates/vcs_menu/src/lib.rs rename to crates/git_ui/src/branch_picker.rs index e4a63d9f0f8ef20030c63ec662245ead1307db2f..bff1c8bf52431cab9746e2558b549de931aefb69 100644 --- a/crates/vcs_menu/src/lib.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1,27 +1,49 @@ use anyhow::{anyhow, Context as _, Result}; use fuzzy::{StringMatch, StringMatchCandidate}; + use git::repository::Branch; use gpui::{ - rems, AnyElement, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, Task, WeakEntity, Window, + rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + Task, WeakEntity, Window, }; use picker::{Picker, PickerDelegate}; use project::ProjectPath; -use std::{ops::Not, sync::Arc}; +use std::sync::Arc; use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; -use zed_actions::branches::OpenRecent; pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { - workspace.register_action(BranchList::open); + workspace.register_action(open); }) .detach(); } +pub fn open( + _: &mut Workspace, + _: &zed_actions::git::Branch, + window: &mut Window, + cx: &mut Context, +) { + let this = cx.entity().clone(); + cx.spawn_in(window, |_, mut cx| async move { + // Modal branch picker has a longer trailoff than a popover one. + let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?; + + this.update_in(&mut cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + BranchList::new(delegate, 34., window, cx) + }) + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None) +} + pub struct BranchList { pub picker: Entity>, rem_width: f32, @@ -29,29 +51,7 @@ pub struct BranchList { } impl BranchList { - pub fn open( - _: &mut Workspace, - _: &OpenRecent, - window: &mut Window, - cx: &mut Context, - ) { - let this = cx.entity().clone(); - cx.spawn_in(window, |_, mut cx| async move { - // Modal branch picker has a longer trailoff than a popover one. - let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?; - - this.update_in(&mut cx, |workspace, window, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - BranchList::new(delegate, 34., window, cx) - }) - })?; - - Ok(()) - }) - .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None) - } - - fn new( + pub fn new( delegate: BranchListDelegate, rem_width: f32, window: &mut Window, @@ -91,6 +91,7 @@ impl Render for BranchList { #[derive(Debug, Clone)] enum BranchEntry { Branch(StringMatch), + History(String), NewBranch { name: String }, } @@ -98,6 +99,7 @@ impl BranchEntry { fn name(&self) -> &str { match self { Self::Branch(branch) => &branch.string, + Self::History(branch) => &branch, Self::NewBranch { name } => &name, } } @@ -114,7 +116,7 @@ pub struct BranchListDelegate { } impl BranchListDelegate { - async fn new( + pub async fn new( workspace: Entity, branch_name_trailoff_after: usize, cx: &AsyncApp, @@ -141,7 +143,7 @@ impl BranchListDelegate { }) } - fn branch_count(&self) -> usize { + pub fn branch_count(&self) -> usize { self.matches .iter() .filter(|item| matches!(item, BranchEntry::Branch(_))) @@ -207,16 +209,10 @@ impl PickerDelegate for BranchListDelegate { let Some(candidates) = candidates.log_err() else { return; }; - let matches = if query.is_empty() { + let matches: Vec = if query.is_empty() { candidates .into_iter() - .enumerate() - .map(|(index, candidate)| StringMatch { - candidate_id: index, - string: candidate.string, - positions: Vec::new(), - score: 0.0, - }) + .map(|candidate| BranchEntry::History(candidate.string)) .collect() } else { fuzzy::match_strings( @@ -228,11 +224,15 @@ impl PickerDelegate for BranchListDelegate { cx.background_executor().clone(), ) .await + .iter() + .cloned() + .map(BranchEntry::Branch) + .collect() }; picker .update(&mut cx, |picker, _| { let delegate = &mut picker.delegate; - delegate.matches = matches.into_iter().map(BranchEntry::Branch).collect(); + delegate.matches = matches; if delegate.matches.is_empty() { if !query.is_empty() { delegate.matches.push(BranchEntry::NewBranch { @@ -268,6 +268,7 @@ impl PickerDelegate for BranchListDelegate { let project = workspace.read(cx).project().read(cx); let branch_to_checkout = match branch { BranchEntry::Branch(branch) => branch.string, + BranchEntry::History(string) => string, BranchEntry::NewBranch { name: branch_name } => branch_name, }; let worktree = project @@ -311,7 +312,14 @@ impl PickerDelegate for BranchListDelegate { .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .map(|parent| match hit { + .when(matches!(hit, BranchEntry::History(_)), |el| { + el.end_slot( + Icon::new(IconName::HistoryRerun) + .color(Color::Muted) + .size(IconSize::Small), + ) + }) + .map(|el| match hit { BranchEntry::Branch(branch) => { let highlights: Vec<_> = branch .positions @@ -320,40 +328,13 @@ impl PickerDelegate for BranchListDelegate { .copied() .collect(); - parent.child(HighlightedLabel::new(shortened_branch_name, highlights)) + el.child(HighlightedLabel::new(shortened_branch_name, highlights)) } + BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)), BranchEntry::NewBranch { name } => { - parent.child(Label::new(format!("Create branch '{name}'"))) + el.child(Label::new(format!("Create branch '{name}'"))) } }), ) } - - fn render_header( - &self, - _window: &mut Window, - _: &mut Context>, - ) -> Option { - let label = if self.last_query.is_empty() { - Label::new("Recent Branches") - .size(LabelSize::Small) - .mt_1() - .ml_3() - .into_any_element() - } else { - let match_label = self.matches.is_empty().not().then(|| { - let suffix = if self.branch_count() == 1 { "" } else { "es" }; - Label::new(format!("{} match{}", self.branch_count(), suffix)) - .color(Color::Muted) - .size(LabelSize::Small) - }); - h_flex() - .px_3() - .justify_between() - .child(Label::new("Branches").size(LabelSize::Small)) - .children(match_label) - .into_any_element() - }; - Some(v_flex().mt_1().child(label).into_any_element()) - } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 7071504cff286e4844171a252517a965a082d89e..d8a676313c8878bcd79df25f57ae935d94397a39 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -6,33 +6,32 @@ use crate::{ }; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; -use editor::actions::MoveToEnd; -use editor::scroll::ScrollbarAutoHide; -use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar}; -use git::repository::RepoPath; -use git::status::FileStatus; -use git::{Commit, ToggleStaged}; +use editor::{ + actions::MoveToEnd, scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, + EditorSettings, MultiBuffer, ShowScrollbar, +}; +use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged}; use gpui::*; use language::{Buffer, File}; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; use multi_buffer::ExcerptInfo; -use panel::PanelHeader; -use project::git::{GitEvent, Repository}; -use project::{Fs, Project, ProjectPath}; +use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader}; +use project::{ + git::{GitEvent, Repository}, + Fs, Project, ProjectPath, +}; use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize}; -use theme::ThemeSettings; use ui::{ - prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, IndentGuideColors, - ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, + prelude::*, ButtonLike, Checkbox, CheckboxWithLabel, Divider, DividerColor, ElevationIndex, + IndentGuideColors, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, }; use util::{maybe, ResultExt, TryFutureExt}; -use workspace::notifications::{DetachAndPromptErr, NotificationId}; -use workspace::Toast; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - Workspace, + notifications::{DetachAndPromptErr, NotificationId}, + Toast, Workspace, }; actions!( @@ -147,33 +146,33 @@ struct PendingOperation { } pub struct GitPanel { + active_repository: Option>, + commit_editor: Entity, + conflicted_count: usize, + conflicted_staged_count: usize, current_modifiers: Modifiers, + enable_auto_coauthors: bool, + entries: Vec, + entries_by_path: collections::HashMap, focus_handle: FocusHandle, fs: Arc, hide_scrollbar_task: Option>, + new_count: usize, + new_staged_count: usize, + pending: Vec, + pending_commit: Option>, pending_serialization: Task>, - workspace: WeakEntity, project: Entity, - active_repository: Option>, + repository_selector: Entity, scroll_handle: UniformListScrollHandle, scrollbar_state: ScrollbarState, selected_entry: Option, show_scrollbar: bool, + tracked_count: usize, + tracked_staged_count: usize, update_visible_entries_task: Task<()>, - repository_selector: Entity, - commit_editor: Entity, - entries: Vec, - entries_by_path: collections::HashMap, width: Option, - pending: Vec, - pending_commit: Option>, - - conflicted_staged_count: usize, - conflicted_count: usize, - tracked_staged_count: usize, - tracked_count: usize, - new_staged_count: usize, - new_count: usize, + workspace: WeakEntity, } fn commit_message_editor( @@ -181,23 +180,10 @@ fn commit_message_editor( window: &mut Window, cx: &mut Context<'_, Editor>, ) -> Editor { - let theme = ThemeSettings::get_global(cx); - - let mut text_style = window.text_style(); - let refinement = TextStyleRefinement { - font_family: Some(theme.buffer_font.family.clone()), - font_features: Some(FontFeatures::disable_ligatures()), - font_size: Some(px(12.).into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(gpui::transparent_black()), - ..Default::default() - }; - text_style.refine(&refinement); - let mut commit_editor = if let Some(commit_message_buffer) = commit_message_buffer { let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx)); Editor::new( - EditorMode::AutoHeight { max_lines: 10 }, + EditorMode::AutoHeight { max_lines: 6 }, buffer, None, false, @@ -205,13 +191,12 @@ fn commit_message_editor( cx, ) } else { - Editor::auto_height(10, window, cx) + Editor::auto_height(6, window, cx) }; commit_editor.set_use_autoclose(false); commit_editor.set_show_gutter(false, cx); commit_editor.set_show_wrap_guides(false, cx); commit_editor.set_show_indent_guides(false, cx); - commit_editor.set_text_style_refinement(refinement); commit_editor.set_placeholder_text("Enter commit message", cx); commit_editor } @@ -260,37 +245,40 @@ impl GitPanel { ) .detach(); + let scrollbar_state = + ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()); + let repository_selector = cx.new(|cx| RepositorySelector::new(project.clone(), window, cx)); let mut git_panel = Self { - focus_handle: cx.focus_handle(), - pending_serialization: Task::ready(None), + active_repository, + commit_editor, + conflicted_count: 0, + conflicted_staged_count: 0, + current_modifiers: window.modifiers(), + enable_auto_coauthors: true, entries: Vec::new(), entries_by_path: HashMap::default(), + focus_handle: cx.focus_handle(), + fs, + hide_scrollbar_task: None, + new_count: 0, + new_staged_count: 0, pending: Vec::new(), - current_modifiers: window.modifiers(), - width: Some(px(360.)), - scrollbar_state: ScrollbarState::new(scroll_handle.clone()) - .parent_entity(&cx.entity()), + pending_commit: None, + pending_serialization: Task::ready(None), + project, repository_selector, + scroll_handle, + scrollbar_state, selected_entry: None, show_scrollbar: false, - hide_scrollbar_task: None, + tracked_count: 0, + tracked_staged_count: 0, update_visible_entries_task: Task::ready(()), - pending_commit: None, - active_repository, - scroll_handle, - fs, - commit_editor, - project, + width: Some(px(360.)), workspace, - conflicted_count: 0, - conflicted_staged_count: 0, - tracked_staged_count: 0, - tracked_count: 0, - new_staged_count: 0, - new_count: 0, }; git_panel.schedule_update(false, window, cx); git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx); @@ -990,6 +978,26 @@ impl GitPanel { cx.notify(); } + fn toggle_auto_coauthors(&mut self, cx: &mut Context) { + self.enable_auto_coauthors = !self.enable_auto_coauthors; + cx.notify(); + } + + fn header_state(&self, header_type: Section) -> ToggleState { + let (staged_count, count) = match header_type { + Section::New => (self.new_staged_count, self.new_count), + Section::Tracked => (self.tracked_staged_count, self.tracked_count), + Section::Conflict => (self.conflicted_staged_count, self.conflicted_count), + }; + if staged_count == 0 { + ToggleState::Unselected + } else if count == staged_count { + ToggleState::Selected + } else { + ToggleState::Indeterminate + } + } + fn update_counts(&mut self, repo: &Repository) { self.conflicted_count = 0; self.conflicted_staged_count = 0; @@ -1043,21 +1051,6 @@ impl GitPanel { self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count } - fn header_state(&self, header_type: Section) -> ToggleState { - let (staged_count, count) = match header_type { - Section::New => (self.new_staged_count, self.new_count), - Section::Tracked => (self.tracked_staged_count, self.tracked_count), - Section::Conflict => (self.conflicted_staged_count, self.conflicted_count), - }; - if staged_count == 0 { - ToggleState::Unselected - } else if count == staged_count { - ToggleState::Selected - } else { - ToggleState::Indeterminate - } - } - fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) { let Some(workspace) = self.workspace.upgrade() else { return; @@ -1110,33 +1103,43 @@ impl GitPanel { .git_state() .read(cx) .all_repositories(); - let entry_count = self + + let branch = self .active_repository .as_ref() - .map_or(0, |repo| repo.read(cx).entry_count()); + .and_then(|repository| repository.read(cx).branch()) + .unwrap_or_else(|| "(no current branch)".into()); + + let has_repo_above = all_repositories.iter().any(|repo| { + repo.read(cx) + .repository_entry + .work_directory + .is_above_project() + }); - let changes_string = match entry_count { - 0 => "No changes".to_string(), - 1 => "1 change".to_string(), - n => format!("{} changes", n), - }; + let icon_button = Button::new("branch-selector", branch) + .color(Color::Muted) + .style(ButtonStyle::Subtle) + .icon(IconName::GitBranch) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .size(ButtonSize::Compact) + .icon_position(IconPosition::Start) + .tooltip(Tooltip::for_action_title( + "Switch Branch", + &zed_actions::git::Branch, + )) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); + })) + .style(ButtonStyle::Transparent); self.panel_header_container(window, cx) - .child(h_flex().gap_2().child(if all_repositories.len() <= 1 { - div() - .id("changes-label") - .text_buffer(cx) - .text_ui_sm(cx) - .child( - Label::new(changes_string) - .single_line() - .size(LabelSize::Small), - ) - .into_any_element() - } else { - self.render_repository_selector(cx).into_any_element() - })) + .child(h_flex().pl_1().child(icon_button)) .child(div().flex_grow()) + .when(all_repositories.len() > 1 || has_repo_above, |el| { + el.child(self.render_repository_selector(cx)) + }) } pub fn render_repository_selector(&self, cx: &mut Context) -> impl IntoElement { @@ -1146,45 +1149,30 @@ impl GitPanel { .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx)) .unwrap_or_default(); - let entry_count = self.entries.len(); - RepositorySelectorPopoverMenu::new( self.repository_selector.clone(), ButtonLike::new("active-repository") .style(ButtonStyle::Subtle) - .child( - h_flex().w_full().gap_0p5().child( - div() - .overflow_x_hidden() - .flex_grow() - .whitespace_nowrap() - .child( - h_flex() - .gap_1() - .child( - Label::new(repository_display_name).size(LabelSize::Small), - ) - .when(entry_count > 0, |flex| { - flex.child( - Label::new(format!("({})", entry_count)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .into_any_element(), - ), - ), - ), + .child(Label::new(repository_display_name).size(LabelSize::Small)), + Tooltip::text("Select a repository"), ) } - pub fn render_commit_editor(&self, cx: &Context) -> impl IntoElement { + pub fn render_commit_editor( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let editor = self.commit_editor.clone(); let can_commit = (self.has_staged_changes() || self.has_tracked_changes()) && self.pending_commit.is_none() && !editor.read(cx).is_empty(cx) && !self.has_unstaged_conflicts() && self.has_write_access(cx); + // let can_commit_all = + // !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx); + let panel_editor_style = panel_editor_style(true, window, cx); + let editor_focus_handle = editor.read(cx).focus_handle(cx).clone(); let focus_handle_1 = self.focus_handle(cx).clone(); @@ -1199,8 +1187,7 @@ impl GitPanel { "Commit All" }; - let commit_button = self - .panel_button("commit-changes", title) + let commit_button = panel_filled_button(title) .tooltip(move |window, cx| { let focus_handle = focus_handle_1.clone(); Tooltip::for_action_in(tooltip, &Commit, &focus_handle, window, cx) @@ -1210,28 +1197,50 @@ impl GitPanel { cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx)) }); - div().w_full().h(px(140.)).px_2().pt_1().pb_2().child( - v_flex() - .id("commit-editor-container") - .relative() - .h_full() - .py_2p5() - .px_3() - .bg(cx.theme().colors().editor_background) - .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| { - window.focus(&editor_focus_handle); - })) - .child(self.commit_editor.clone()) - .child( - h_flex() - .absolute() - .bottom_2p5() - .right_3() - .gap_1p5() - .child(div().gap_1().flex_grow()) - .child(commit_button), - ), - ) + let enable_coauthors = CheckboxWithLabel::new( + "enable-coauthors", + Label::new("Add Co-authors") + .color(Color::Disabled) + .size(LabelSize::XSmall), + self.enable_auto_coauthors.into(), + cx.listener(move |this, _, _, cx| this.toggle_auto_coauthors(cx)), + ); + + let footer_size = px(32.); + let gap = px(16.0); + + let max_height = window.line_height() * 6. + gap + footer_size; + + panel_editor_container(window, cx) + .id("commit-editor-container") + .relative() + .h(max_height) + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| { + window.focus(&editor_focus_handle); + })) + .child(EditorElement::new(&self.commit_editor, panel_editor_style)) + .child( + h_flex() + .absolute() + .bottom_0() + .left_2() + .h(footer_size) + .flex_none() + .child(enable_coauthors), + ) + .child( + h_flex() + .absolute() + .bottom_0() + .right_2() + .h(footer_size) + .flex_none() + .child(commit_button), + ) } fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { @@ -1243,7 +1252,11 @@ impl GitPanel { .child( v_flex() .gap_3() - .child("No changes to commit") + .child(if self.active_repository.is_some() { + "No changes to commit" + } else { + "No Git repositories" + }) .text_ui_sm(cx) .mx_auto() .text_color(Color::Placeholder.color(cx)), @@ -1357,6 +1370,7 @@ impl GitPanel { v_flex() .size_full() + .flex_grow() .overflow_hidden() .child( uniform_list(cx.entity().clone(), "entries", entry_count, { @@ -1505,7 +1519,7 @@ impl GitPanel { .spacing(ListItemSpacing::Sparse) .start_slot(start_slot) .toggle_state(selected) - .focused(selected && self.focus_handle.is_focused(window)) + .focused(selected && self.focus_handle(cx).is_focused(window)) .disabled(!has_write_access) .on_click({ cx.listener(move |this, _, _, cx| { @@ -1608,7 +1622,7 @@ impl GitPanel { .spacing(ListItemSpacing::Sparse) .start_slot(start_slot) .toggle_state(selected) - .focused(selected && self.focus_handle.is_focused(window)) + .focused(selected && self.focus_handle(cx).is_focused(window)) .disabled(!has_write_access) .on_click({ cx.listener(move |this, _, window, cx| { @@ -1714,7 +1728,7 @@ impl Render for GitPanel { } else { self.render_empty_state(cx).into_any_element() }) - .child(self.render_commit_editor(cx)) + .child(self.render_commit_editor(window, cx)) } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 3757daaf7e64ae46e6c80317adc3e973c9674d38..300c589ecd910c51e29cef22587930310ae8b68d 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -5,14 +5,18 @@ use gpui::App; use project_diff::ProjectDiff; use ui::{ActiveTheme, Color, Icon, IconName, IntoElement}; +pub mod branch_picker; pub mod git_panel; mod git_panel_settings; pub mod project_diff; +// mod quick_commit; pub mod repository_selector; pub fn init(cx: &mut App) { GitPanelSettings::register(cx); + branch_picker::init(cx); cx.observe_new(ProjectDiff::register).detach(); + // quick_commit::init(cx); } // TODO: Add updated status colors to theme diff --git a/crates/git_ui/src/quick_commit.rs b/crates/git_ui/src/quick_commit.rs new file mode 100644 index 0000000000000000000000000000000000000000..be7f3fa84db40465fdbdbe411c1e9d0d567d2de9 --- /dev/null +++ b/crates/git_ui/src/quick_commit.rs @@ -0,0 +1,307 @@ +#![allow(unused, dead_code)] + +use crate::repository_selector::RepositorySelector; +use anyhow::Result; +use git::{CommitAllChanges, CommitChanges}; +use language::Buffer; +use panel::{panel_editor_container, panel_editor_style, panel_filled_button, panel_icon_button}; +use ui::{prelude::*, Tooltip}; + +use editor::{Editor, EditorElement, EditorMode, MultiBuffer}; +use gpui::*; +use project::git::Repository; +use project::{Fs, Project}; +use std::sync::Arc; +use workspace::{ModalView, Workspace}; + +actions!( + git, + [QuickCommitWithMessage, QuickCommitStaged, QuickCommitAll] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, window, cx| { + let Some(window) = window else { + return; + }; + QuickCommitModal::register(workspace, window, cx) + }) + .detach(); +} + +fn commit_message_editor( + commit_message_buffer: Option>, + window: &mut Window, + cx: &mut Context<'_, Editor>, +) -> Editor { + let mut commit_editor = if let Some(commit_message_buffer) = commit_message_buffer { + let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx)); + Editor::new( + EditorMode::AutoHeight { max_lines: 10 }, + buffer, + None, + false, + window, + cx, + ) + } else { + Editor::auto_height(10, window, cx) + }; + commit_editor.set_use_autoclose(false); + commit_editor.set_show_gutter(false, cx); + commit_editor.set_show_wrap_guides(false, cx); + commit_editor.set_show_indent_guides(false, cx); + commit_editor.set_placeholder_text("Enter commit message", cx); + commit_editor +} + +pub struct QuickCommitModal { + focus_handle: FocusHandle, + fs: Arc, + project: Entity, + active_repository: Option>, + repository_selector: Entity, + commit_editor: Entity, + width: Option, + commit_task: Task>, + commit_pending: bool, + can_commit: bool, + can_commit_all: bool, + enable_auto_coauthors: bool, +} + +impl Focusable for QuickCommitModal { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for QuickCommitModal {} +impl ModalView for QuickCommitModal {} + +impl QuickCommitModal { + pub fn register(workspace: &mut Workspace, _: &mut Window, cx: &mut Context) { + workspace.register_action(|workspace, _: &QuickCommitWithMessage, window, cx| { + let project = workspace.project().clone(); + let fs = workspace.app_state().fs.clone(); + + workspace.toggle_modal(window, cx, move |window, cx| { + QuickCommitModal::new(project, fs, window, None, cx) + }); + }); + } + + pub fn new( + project: Entity, + fs: Arc, + window: &mut Window, + commit_message_buffer: Option>, + cx: &mut Context, + ) -> Self { + let git_state = project.read(cx).git_state().clone(); + let active_repository = project.read(cx).active_repository(cx); + + let focus_handle = cx.focus_handle(); + + let commit_editor = cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx)); + commit_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }); + + let repository_selector = cx.new(|cx| RepositorySelector::new(project.clone(), window, cx)); + + Self { + focus_handle, + fs, + project, + active_repository, + repository_selector, + commit_editor, + width: None, + commit_task: Task::ready(Ok(())), + commit_pending: false, + can_commit: false, + can_commit_all: false, + enable_auto_coauthors: true, + } + } + + pub fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let all_repositories = self + .project + .read(cx) + .git_state() + .read(cx) + .all_repositories(); + let entry_count = self + .active_repository + .as_ref() + .map_or(0, |repo| repo.read(cx).entry_count()); + + let changes_string = match entry_count { + 0 => "No changes".to_string(), + 1 => "1 change".to_string(), + n => format!("{} changes", n), + }; + + div().absolute().top_0().right_0().child( + panel_icon_button("open_change_list", IconName::PanelRight) + .disabled(true) + .tooltip(Tooltip::text("Changes list coming soon!")), + ) + } + + pub fn render_commit_editor( + &self, + name_and_email: Option<(SharedString, SharedString)>, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let editor = self.commit_editor.clone(); + let can_commit = !self.commit_pending && self.can_commit && !editor.read(cx).is_empty(cx); + let editor_focus_handle = editor.read(cx).focus_handle(cx).clone(); + + let focus_handle_1 = self.focus_handle(cx).clone(); + let focus_handle_2 = self.focus_handle(cx).clone(); + + let panel_editor_style = panel_editor_style(true, window, cx); + + let commit_staged_button = panel_filled_button("Commit") + .tooltip(move |window, cx| { + let focus_handle = focus_handle_1.clone(); + Tooltip::for_action_in( + "Commit all staged changes", + &CommitChanges, + &focus_handle, + window, + cx, + ) + }) + .when(!can_commit, |this| { + this.disabled(true).style(ButtonStyle::Transparent) + }); + // .on_click({ + // let name_and_email = name_and_email.clone(); + // cx.listener(move |this, _: &ClickEvent, window, cx| { + // this.commit_changes(&CommitChanges, name_and_email.clone(), window, cx) + // }) + // }); + + let commit_all_button = panel_filled_button("Commit All") + .tooltip(move |window, cx| { + let focus_handle = focus_handle_2.clone(); + Tooltip::for_action_in( + "Commit all changes, including unstaged changes", + &CommitAllChanges, + &focus_handle, + window, + cx, + ) + }) + .when(!can_commit, |this| { + this.disabled(true).style(ButtonStyle::Transparent) + }); + // .on_click({ + // let name_and_email = name_and_email.clone(); + // cx.listener(move |this, _: &ClickEvent, window, cx| { + // this.commit_tracked_changes( + // &CommitAllChanges, + // name_and_email.clone(), + // window, + // cx, + // ) + // }) + // }); + + let co_author_button = panel_icon_button("add-co-author", IconName::UserGroup) + .icon_color(if self.enable_auto_coauthors { + Color::Muted + } else { + Color::Accent + }) + .icon_size(IconSize::Small) + .toggle_state(self.enable_auto_coauthors) + // .on_click({ + // cx.listener(move |this, _: &ClickEvent, _, cx| { + // this.toggle_auto_coauthors(cx); + // }) + // }) + .tooltip(move |window, cx| { + Tooltip::with_meta( + "Toggle automatic co-authors", + None, + "Automatically adds current collaborators", + window, + cx, + ) + }); + + panel_editor_container(window, cx) + .id("commit-editor-container") + .relative() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border) + .h(px(140.)) + .bg(cx.theme().colors().editor_background) + .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| { + window.focus(&editor_focus_handle); + })) + .child(EditorElement::new(&self.commit_editor, panel_editor_style)) + .child(div().flex_1()) + .child( + h_flex() + .items_center() + .h_8() + .justify_between() + .gap_1() + .child(co_author_button) + .child(commit_all_button) + .child(commit_staged_button), + ) + } + + pub fn render_footer(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .child(h_flex().child("cmd+esc clear message")) + .child( + h_flex() + .child(panel_filled_button("Commit")) + .child(panel_filled_button("Commit All")), + ) + } + + fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl Render for QuickCommitModal { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + v_flex() + .id("quick-commit-modal") + .key_context("QuickCommit") + .on_action(cx.listener(Self::dismiss)) + .relative() + .bg(cx.theme().colors().elevated_surface_background) + .rounded(px(16.)) + .border_1() + .border_color(cx.theme().colors().border) + .py_2() + .px_4() + .w(self.width.unwrap_or(px(640.))) + .h(px(450.)) + .flex_1() + .overflow_hidden() + .child(self.render_header(window, cx)) + .child( + v_flex() + .flex_1() + // TODO: pass name_and_email + .child(self.render_commit_editor(None, window, cx)), + ) + .child(self.render_footer(window, cx)) + } +} diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 81d5f06635d6a7c387fb8ad44cf1b3d8c47f02d1..e5d9c1839a90bfe94e4c0440ac2d5bcfc001f496 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -1,6 +1,6 @@ use gpui::{ - AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, - Task, WeakEntity, + AnyElement, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, Task, WeakEntity, }; use picker::{Picker, PickerDelegate}; use project::{ @@ -34,6 +34,7 @@ impl RepositorySelector { let picker = cx.new(|cx| { Picker::nonsearchable_uniform_list(delegate, window, cx) .max_height(Some(rems(20.).into())) + .width(rems(15.)) }); let _subscriptions = @@ -78,20 +79,27 @@ impl Render for RepositorySelector { } #[derive(IntoElement)] -pub struct RepositorySelectorPopoverMenu +pub struct RepositorySelectorPopoverMenu where - T: PopoverTrigger, + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, { repository_selector: Entity, trigger: T, + tooltip: TT, handle: Option>, } -impl RepositorySelectorPopoverMenu { - pub fn new(repository_selector: Entity, trigger: T) -> Self { +impl RepositorySelectorPopoverMenu +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ + pub fn new(repository_selector: Entity, trigger: T, tooltip: TT) -> Self { Self { repository_selector, trigger, + tooltip, handle: None, } } @@ -102,13 +110,17 @@ impl RepositorySelectorPopoverMenu { } } -impl RenderOnce for RepositorySelectorPopoverMenu { +impl RenderOnce for RepositorySelectorPopoverMenu +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let repository_selector = self.repository_selector.clone(); PopoverMenu::new("repository-switcher") .menu(move |_window, _cx| Some(repository_selector.clone())) - .trigger(self.trigger) + .trigger_with_tooltip(self.trigger, self.tooltip) .attach(gpui::Corner::BottomLeft) .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle)) } diff --git a/crates/gpui/examples/data_table.rs b/crates/gpui/examples/data_table.rs new file mode 100644 index 0000000000000000000000000000000000000000..8a70b5546624d9ffc0ce0c3a8c295545167fd0c5 --- /dev/null +++ b/crates/gpui/examples/data_table.rs @@ -0,0 +1,479 @@ +use std::{ + ops::Range, + rc::Rc, + time::{Duration, Instant}, +}; + +use gpui::{ + canvas, div, point, prelude::*, px, rgb, size, uniform_list, App, Application, Bounds, Context, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, SharedString, + UniformListScrollHandle, Window, WindowBounds, WindowOptions, +}; + +const TOTAL_ITEMS: usize = 10000; +const SCROLLBAR_THUMB_WIDTH: Pixels = px(8.); +const SCROLLBAR_THUMB_HEIGHT: Pixels = px(100.); + +pub struct Quote { + name: SharedString, + symbol: SharedString, + last_done: f64, + prev_close: f64, + open: f64, + high: f64, + low: f64, + timestamp: Instant, + volume: i64, + turnover: f64, + ttm: f64, + market_cap: f64, + float_cap: f64, + shares: f64, + pb: f64, + pe: f64, + eps: f64, + dividend: f64, + dividend_yield: f64, + dividend_per_share: f64, + dividend_date: SharedString, + dividend_payment: f64, +} + +impl Quote { + pub fn random() -> Self { + use rand::Rng; + let mut rng = rand::thread_rng(); + // simulate a base price in a realistic range + let prev_close = rng.gen_range(100.0..200.0); + let change = rng.gen_range(-5.0..5.0); + let last_done = prev_close + change; + let open = prev_close + rng.gen_range(-3.0..3.0); + let high = (prev_close + rng.gen_range::(0.0..10.0)).max(open); + let low = (prev_close - rng.gen_range::(0.0..10.0)).min(open); + // Randomize the timestamp in the past 24 hours + let timestamp = Instant::now() - Duration::from_secs(rng.gen_range(0..86400)); + let volume = rng.gen_range(1_000_000..100_000_000); + let turnover = last_done * volume as f64; + let symbol = { + let mut ticker = String::new(); + if rng.gen_bool(0.5) { + ticker.push_str(&format!( + "{:03}.{}", + rng.gen_range(100..1000), + rng.gen_range(0..10) + )); + } else { + ticker.push_str(&format!( + "{}{}", + rng.gen_range('A'..='Z'), + rng.gen_range('A'..='Z') + )); + } + ticker.push_str(&format!(".{}", rng.gen_range('A'..='Z'))); + ticker + }; + let name = format!( + "{} {} - #{}", + symbol, + rng.gen_range(1..100), + rng.gen_range(10000..100000) + ); + let ttm = rng.gen_range(0.0..10.0); + let market_cap = rng.gen_range(1_000_000.0..10_000_000.0); + let float_cap = market_cap + rng.gen_range(1_000.0..10_000.0); + let shares = rng.gen_range(100.0..1000.0); + let pb = market_cap / shares; + let pe = market_cap / shares; + let eps = market_cap / shares; + let dividend = rng.gen_range(0.0..10.0); + let dividend_yield = rng.gen_range(0.0..10.0); + let dividend_per_share = rng.gen_range(0.0..10.0); + let dividend_date = SharedString::new(format!( + "{}-{}-{}", + rng.gen_range(2000..2023), + rng.gen_range(1..12), + rng.gen_range(1..28) + )); + let dividend_payment = rng.gen_range(0.0..10.0); + + Self { + name: name.into(), + symbol: symbol.into(), + last_done, + prev_close, + open, + high, + low, + timestamp, + volume, + turnover, + pb, + pe, + eps, + ttm, + market_cap, + float_cap, + shares, + dividend, + dividend_yield, + dividend_per_share, + dividend_date, + dividend_payment, + } + } + + fn change(&self) -> f64 { + (self.last_done - self.prev_close) / self.prev_close * 100.0 + } + + fn change_color(&self) -> gpui::Hsla { + if self.change() > 0.0 { + gpui::green() + } else { + gpui::red() + } + } + + fn turnover_ratio(&self) -> f64 { + self.volume as f64 / self.turnover * 100.0 + } +} + +#[derive(IntoElement)] +struct TableRow { + ix: usize, + quote: Rc, +} +impl TableRow { + fn new(ix: usize, quote: Rc) -> Self { + Self { ix, quote } + } + + fn render_cell(&self, key: &str, width: Pixels, color: gpui::Hsla) -> impl IntoElement { + div() + .whitespace_nowrap() + .truncate() + .w(width) + .px_1() + .child(match key { + "id" => div().child(format!("{}", self.ix)), + "symbol" => div().child(self.quote.symbol.clone()), + "name" => div().child(self.quote.name.clone()), + "last_done" => div() + .text_color(color) + .child(format!("{:.3}", self.quote.last_done)), + "prev_close" => div() + .text_color(color) + .child(format!("{:.3}", self.quote.prev_close)), + "change" => div() + .text_color(color) + .child(format!("{:.2}%", self.quote.change())), + "timestamp" => div() + .text_color(color) + .child(format!("{:?}", self.quote.timestamp.elapsed().as_secs())), + "open" => div() + .text_color(color) + .child(format!("{:.2}", self.quote.open)), + "low" => div() + .text_color(color) + .child(format!("{:.2}", self.quote.low)), + "high" => div() + .text_color(color) + .child(format!("{:.2}", self.quote.high)), + "ttm" => div() + .text_color(color) + .child(format!("{:.2}", self.quote.ttm)), + "eps" => div() + .text_color(color) + .child(format!("{:.2}", self.quote.eps)), + "market_cap" => { + div().child(format!("{:.2} M", self.quote.market_cap / 1_000_000.0)) + } + "float_cap" => div().child(format!("{:.2} M", self.quote.float_cap / 1_000_000.0)), + "turnover" => div().child(format!("{:.2} M", self.quote.turnover / 1_000_000.0)), + "volume" => div().child(format!("{:.2} M", self.quote.volume as f64 / 1_000_000.0)), + "turnover_ratio" => div().child(format!("{:.2}%", self.quote.turnover_ratio())), + "pe" => div().child(format!("{:.2}", self.quote.pe)), + "pb" => div().child(format!("{:.2}", self.quote.pb)), + "shares" => div().child(format!("{:.2}", self.quote.shares)), + "dividend" => div().child(format!("{:.2}", self.quote.dividend)), + "yield" => div().child(format!("{:.2}%", self.quote.dividend_yield)), + "dividend_per_share" => { + div().child(format!("{:.2}", self.quote.dividend_per_share)) + } + "dividend_date" => div().child(format!("{}", self.quote.dividend_date)), + "dividend_payment" => div().child(format!("{:.2}", self.quote.dividend_payment)), + _ => div().child("--"), + }) + } +} + +const FIELDS: [(&str, f32); 24] = [ + ("id", 64.), + ("symbol", 64.), + ("name", 180.), + ("last_done", 80.), + ("prev_close", 80.), + ("open", 80.), + ("low", 80.), + ("high", 80.), + ("ttm", 50.), + ("market_cap", 96.), + ("float_cap", 96.), + ("turnover", 120.), + ("volume", 100.), + ("turnover_ratio", 96.), + ("pe", 64.), + ("pb", 64.), + ("eps", 64.), + ("shares", 96.), + ("dividend", 64.), + ("yield", 64.), + ("dividend_per_share", 64.), + ("dividend_date", 96.), + ("dividend_payment", 64.), + ("timestamp", 120.), +]; + +impl RenderOnce for TableRow { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let color = self.quote.change_color(); + div() + .flex() + .flex_row() + .border_b_1() + .border_color(rgb(0xE0E0E0)) + .bg(if self.ix % 2 == 0 { + rgb(0xFFFFFF) + } else { + rgb(0xFAFAFA) + }) + .py_0p5() + .px_2() + .children(FIELDS.map(|(key, width)| self.render_cell(key, px(width), color))) + } +} + +struct DataTable { + /// Use `Rc` to share the same quote data across multiple items, avoid cloning. + quotes: Vec>, + visible_range: Range, + scroll_handle: UniformListScrollHandle, + /// The position in thumb bounds when dragging start mouse down. + drag_position: Option>, +} + +impl DataTable { + fn new() -> Self { + Self { + quotes: Vec::new(), + visible_range: 0..0, + scroll_handle: UniformListScrollHandle::new(), + drag_position: None, + } + } + + fn generate(&mut self) { + self.quotes = (0..TOTAL_ITEMS).map(|_| Rc::new(Quote::random())).collect(); + } + + fn table_bounds(&self) -> Bounds { + self.scroll_handle.0.borrow().base_handle.bounds() + } + + fn scroll_top(&self) -> Pixels { + self.scroll_handle.0.borrow().base_handle.offset().y + } + + fn scroll_height(&self) -> Pixels { + self.scroll_handle + .0 + .borrow() + .last_item_size + .unwrap_or_default() + .contents + .height + } + + fn render_scrollbar(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let scroll_height = self.scroll_height(); + let table_bounds = self.table_bounds(); + let table_height = table_bounds.size.height; + if table_height == px(0.) { + return div().id("scrollbar"); + } + + let percentage = -self.scroll_top() / scroll_height; + let offset_top = (table_height * percentage).clamp( + px(4.), + (table_height - SCROLLBAR_THUMB_HEIGHT - px(4.)).max(px(4.)), + ); + let entity = cx.entity(); + let scroll_handle = self.scroll_handle.0.borrow().base_handle.clone(); + + div() + .id("scrollbar") + .absolute() + .top(offset_top) + .right_1() + .h(SCROLLBAR_THUMB_HEIGHT) + .w(SCROLLBAR_THUMB_WIDTH) + .bg(rgb(0xC0C0C0)) + .hover(|this| this.bg(rgb(0xA0A0A0))) + .rounded_lg() + .child( + canvas( + |_, _, _| (), + move |thumb_bounds, _, window, _| { + window.on_mouse_event({ + let entity = entity.clone(); + move |ev: &MouseDownEvent, _, _, cx| { + if !thumb_bounds.contains(&ev.position) { + return; + } + + entity.update(cx, |this, _| { + this.drag_position = Some( + ev.position - thumb_bounds.origin - table_bounds.origin, + ); + }) + } + }); + window.on_mouse_event({ + let entity = entity.clone(); + move |_: &MouseUpEvent, _, _, cx| { + entity.update(cx, |this, _| { + this.drag_position = None; + }) + } + }); + + window.on_mouse_event(move |ev: &MouseMoveEvent, _, _, cx| { + if !ev.dragging() { + return; + } + + let Some(drag_pos) = entity.read(cx).drag_position else { + return; + }; + + let inside_offset = drag_pos.y; + let percentage = ((ev.position.y - table_bounds.origin.y + + inside_offset) + / (table_bounds.size.height)) + .clamp(0., 1.); + + let offset_y = ((scroll_height - table_bounds.size.height) + * percentage) + .clamp(px(0.), scroll_height - SCROLLBAR_THUMB_HEIGHT); + scroll_handle.set_offset(point(px(0.), -offset_y)); + cx.notify(entity.entity_id()); + }) + }, + ) + .size_full(), + ) + } +} + +impl Render for DataTable { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity(); + + div() + .font_family(".SystemUIFont") + .bg(gpui::white()) + .text_sm() + .size_full() + .p_4() + .gap_2() + .flex() + .flex_col() + .child(format!( + "Total {} items, visible range: {:?}", + self.quotes.len(), + self.visible_range + )) + .child( + div() + .flex() + .flex_col() + .flex_1() + .overflow_hidden() + .border_1() + .border_color(rgb(0xE0E0E0)) + .rounded_md() + .child( + div() + .flex() + .flex_row() + .w_full() + .overflow_hidden() + .border_b_1() + .border_color(rgb(0xE0E0E0)) + .text_color(rgb(0x555555)) + .bg(rgb(0xF0F0F0)) + .py_1() + .px_2() + .text_xs() + .children(FIELDS.map(|(key, width)| { + div() + .whitespace_nowrap() + .flex_shrink_0() + .truncate() + .px_1() + .w(px(width)) + .child(key.replace("_", " ").to_uppercase()) + })), + ) + .child( + div() + .relative() + .size_full() + .child( + uniform_list(entity, "items", self.quotes.len(), { + move |this, range, _, _| { + this.visible_range = range.clone(); + let mut items = Vec::with_capacity(range.end - range.start); + for i in range { + if let Some(quote) = this.quotes.get(i) { + items.push(TableRow::new(i, quote.clone())); + } + } + items + } + }) + .size_full() + .track_scroll(self.scroll_handle.clone()), + ) + .child(self.render_scrollbar(window, cx)), + ), + ) + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + cx.open_window( + WindowOptions { + focus: true, + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(1280.0), px(1000.0)), + cx, + ))), + ..Default::default() + }, + |_, cx| { + cx.new(|_| { + let mut table = DataTable::new(); + table.generate(); + table + }) + }, + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/src/arena.rs b/crates/gpui/src/arena.rs index 4ddeaaff65eb6247595fd15a263a985a01d2ae23..0e78feca7bfa4c514f233415ba3e4f6f323a6655 100644 --- a/crates/gpui/src/arena.rs +++ b/crates/gpui/src/arena.rs @@ -116,6 +116,7 @@ impl ArenaBox { } } + #[track_caller] fn validate(&self) { assert!( self.valid.get(), diff --git a/crates/inline_completion/src/inline_completion.rs b/crates/inline_completion/src/inline_completion.rs index 6a1754c3773e5f1361930d81e6c9be9fc0c3e8d9..cea21472ca3a95597df61bd9ec87cb4e31233aa2 100644 --- a/crates/inline_completion/src/inline_completion.rs +++ b/crates/inline_completion/src/inline_completion.rs @@ -1,4 +1,4 @@ -use gpui::{App, Context, Entity}; +use gpui::{App, Context, Entity, SharedString}; use language::Buffer; use project::Project; use std::ops::Range; @@ -15,6 +15,8 @@ pub enum Direction { #[derive(Clone)] pub struct InlineCompletion { + /// The ID of the completion, if it has one. + pub id: Option, pub edits: Vec<(Range, String)>, pub edit_preview: Option, } @@ -22,7 +24,7 @@ pub struct InlineCompletion { pub enum DataCollectionState { /// The provider doesn't support data collection. Unsupported, - /// Data collection is enabled + /// Data collection is enabled. Enabled, /// Data collection is disabled or unanswered. Disabled, diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 1864e0c26603885738d73d6040b826a3435e62ef..1b9e7309692bbb6fc8a5a89872aa9fa3e0cabc48 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -1,7 +1,11 @@ use anyhow::Result; use client::UserStore; use copilot::{Copilot, Status}; -use editor::{actions::ShowEditPrediction, scroll::Autoscroll, Editor}; +use editor::{ + actions::{ShowEditPrediction, ToggleEditPrediction}, + scroll::Autoscroll, + Editor, +}; use feature_flags::{ FeatureFlagAppExt, PredictEditsFeatureFlag, PredictEditsRateCompletionsFeatureFlag, }; @@ -44,6 +48,7 @@ struct CopilotErrorToast; pub struct InlineCompletionButton { editor_subscription: Option<(Subscription, usize)>, editor_enabled: Option, + editor_show_predictions: bool, editor_focus_handle: Option, language: Option>, file: Option>, @@ -137,9 +142,12 @@ impl Render for InlineCompletionButton { }) }) .anchor(Corner::BottomRight) - .trigger(IconButton::new("copilot-icon", icon).tooltip(|window, cx| { - Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx) - })) + .trigger_with_tooltip( + IconButton::new("copilot-icon", icon), + |window, cx| { + Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx) + }, + ) .with_handle(self.popover_menu_handle.clone()), ) } @@ -206,7 +214,8 @@ impl Render for InlineCompletionButton { _ => None, }) .anchor(Corner::BottomRight) - .trigger(IconButton::new("supermaven-icon", icon).tooltip( + .trigger_with_tooltip( + IconButton::new("supermaven-icon", icon), move |window, cx| { if has_menu { Tooltip::for_action( @@ -219,7 +228,7 @@ impl Render for InlineCompletionButton { Tooltip::text(tooltip_text.clone())(window, cx) } }, - )) + ) .with_handle(self.popover_menu_handle.clone()), ); } @@ -275,15 +284,29 @@ impl Render for InlineCompletionButton { ); } + let show_editor_predictions = self.editor_show_predictions; + let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon) .shape(IconButtonShape::Square) + .when(enabled && !show_editor_predictions, |this| { + this.indicator(Indicator::dot().color(Color::Muted)) + .indicator_border_color(Some(cx.theme().colors().status_bar_background)) + }) .when(!self.popover_menu_handle.is_deployed(), |element| { - if enabled { - element.tooltip(|window, cx| { - Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx) - }) - } else { - element.tooltip(|window, cx| { + element.tooltip(move |window, cx| { + if enabled { + if show_editor_predictions { + Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx) + } else { + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + "Hidden For This File", + window, + cx, + ) + } + } else { Tooltip::with_meta( "Edit Prediction", Some(&ToggleMenu), @@ -291,8 +314,8 @@ impl Render for InlineCompletionButton { window, cx, ) - }) - } + } + }) }); let this = cx.entity().clone(); @@ -347,6 +370,7 @@ impl InlineCompletionButton { Self { editor_subscription: None, editor_enabled: None, + editor_show_predictions: true, editor_focus_handle: None, language: None, file: None, @@ -384,6 +408,21 @@ impl InlineCompletionButton { menu = menu.header("Show Edit Predictions For"); + if let Some(editor_focus_handle) = self.editor_focus_handle.clone() { + menu = menu.toggleable_entry( + "This File", + self.editor_show_predictions, + IconPosition::Start, + Some(Box::new(ToggleEditPrediction)), + { + let editor_focus_handle = editor_focus_handle.clone(); + move |window, cx| { + editor_focus_handle.dispatch_action(&ToggleEditPrediction, window, cx); + } + }, + ); + } + if let Some(language) = self.language.clone() { let fs = fs.clone(); let language_enabled = @@ -393,7 +432,7 @@ impl InlineCompletionButton { menu = menu.toggleable_entry( language.name(), language_enabled, - IconPosition::End, + IconPosition::Start, None, move |_, cx| { toggle_show_inline_completions_for_language(language.clone(), fs.clone(), cx) @@ -406,7 +445,7 @@ impl InlineCompletionButton { menu = menu.toggleable_entry( "All Files", globally_enabled, - IconPosition::End, + IconPosition::Start, None, move |_, cx| toggle_inline_completions_globally(fs.clone(), cx), ); @@ -422,7 +461,7 @@ impl InlineCompletionButton { // TODO: We want to add something later that communicates whether // the current project is open-source. ContextMenuEntry::new("Share Training Data") - .toggleable(IconPosition::End, data_collection.is_enabled()) + .toggleable(IconPosition::Start, data_collection.is_enabled()) .documentation_aside(|_| { Label::new(indoc!{" Help us improve our open model by sharing data from open source repositories. \ @@ -450,6 +489,8 @@ impl InlineCompletionButton { menu = menu.item( ContextMenuEntry::new("Configure Excluded Files") + .icon(IconName::LockOutlined) + .icon_color(Color::Muted) .documentation_aside(|_| { Label::new(indoc!{" Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element() @@ -486,7 +527,6 @@ impl InlineCompletionButton { Some(Box::new(ShowEditPrediction)), { let editor_focus_handle = editor_focus_handle.clone(); - move |window, cx| { editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx); } @@ -571,6 +611,7 @@ impl InlineCompletionButton { .unwrap_or(true), ) }; + self.editor_show_predictions = editor.should_show_inline_completions(cx); self.edit_prediction_provider = editor.edit_prediction_provider(); self.language = language.cloned(); self.file = file; diff --git a/crates/language_model_selector/src/language_model_selector.rs b/crates/language_model_selector/src/language_model_selector.rs index 10e8b57d684bd6d9b446fd62a120eb568153e900..78e5ed29b44592fd4333f80a81e383fa66f2a2c0 100644 --- a/crates/language_model_selector/src/language_model_selector.rs +++ b/crates/language_model_selector/src/language_model_selector.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use feature_flags::ZedPro; use gpui::{ - Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Action, AnyElement, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, }; use language_model::{LanguageModel, LanguageModelAvailability, LanguageModelRegistry}; @@ -115,20 +115,31 @@ impl Render for LanguageModelSelector { } #[derive(IntoElement)] -pub struct LanguageModelSelectorPopoverMenu +pub struct LanguageModelSelectorPopoverMenu where - T: PopoverTrigger, + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, { language_model_selector: Entity, trigger: T, + tooltip: TT, handle: Option>, } -impl LanguageModelSelectorPopoverMenu { - pub fn new(language_model_selector: Entity, trigger: T) -> Self { +impl LanguageModelSelectorPopoverMenu +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ + pub fn new( + language_model_selector: Entity, + trigger: T, + tooltip: TT, + ) -> Self { Self { language_model_selector, trigger, + tooltip, handle: None, } } @@ -139,13 +150,17 @@ impl LanguageModelSelectorPopoverMenu { } } -impl RenderOnce for LanguageModelSelectorPopoverMenu { +impl RenderOnce for LanguageModelSelectorPopoverMenu +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let language_model_selector = self.language_model_selector.clone(); PopoverMenu::new("model-switcher") .menu(move |_window, _cx| Some(language_model_selector.clone())) - .trigger(self.trigger) + .trigger_with_tooltip(self.trigger, self.tooltip) .anchor(gpui::Corner::BottomRight) .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle)) .offset(gpui::Point { diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 9db4a97fa9e3b998fc7052b36e2f4fda420ef829..3dbdfa2b9125d686816345c225adadf7ce1c6e0f 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -293,7 +293,7 @@ impl SyntaxTreeView { impl Render for SyntaxTreeView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let mut rendered = div().flex_1(); + let mut rendered = div().flex_1().bg(cx.theme().colors().editor_background); if let Some(layer) = self .editor diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 9c80fcedd3ebbaf443d1cce3c5c7ae81a59763d5..0e8359412a8d0a4eeb047ad3fcefe112e035f85f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -7320,6 +7320,7 @@ impl ToOffset for Point { } impl ToOffset for usize { + #[track_caller] fn to_offset<'a>(&self, snapshot: &MultiBufferSnapshot) -> usize { assert!(*self <= snapshot.len(), "offset is out of range"); *self diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 212d13555f3e580db9d66bc21296aa23aed62ed1..b5b637ce04f88fe6ba75dd7d73645a3004c19e36 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -2,10 +2,14 @@ mod outline_panel_settings; use std::{ cmp, + collections::BTreeMap, hash::Hash, ops::Range, path::{Path, PathBuf, MAIN_SEPARATOR_STR}, - sync::{atomic::AtomicBool, Arc, OnceLock}, + sync::{ + atomic::{self, AtomicBool}, + Arc, OnceLock, + }, time::Duration, u32, }; @@ -103,6 +107,7 @@ pub struct OutlinePanel { active_item: Option, _subscriptions: Vec, updating_fs_entries: bool, + updating_cached_entries: bool, new_entries_for_fs_update: HashSet, fs_entries_update_task: Task<()>, cached_entries_update_task: Task<()>, @@ -777,7 +782,10 @@ impl OutlinePanel { excerpt.invalidate_outlines(); } } - outline_panel.update_non_fs_items(window, cx); + let update_cached_items = outline_panel.update_non_fs_items(window, cx); + if update_cached_items { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } } else if &outline_panel_settings != new_settings { outline_panel_settings = *new_settings; cx.notify(); @@ -814,6 +822,7 @@ impl OutlinePanel { active_item: None, pending_serialization: Task::ready(None), updating_fs_entries: false, + updating_cached_entries: false, new_entries_for_fs_update: HashSet::default(), preserve_selection_on_buffer_fold_toggles: HashSet::default(), fs_entries_update_task: Task::ready(()), @@ -922,7 +931,7 @@ impl OutlinePanel { cx.propagate() } else if let Some(selected_entry) = self.selected_entry().cloned() { self.toggle_expanded(&selected_entry, window, cx); - self.scroll_editor_to_entry(&selected_entry, true, false, window, cx); + self.scroll_editor_to_entry(&selected_entry, true, true, window, cx); } } @@ -977,7 +986,7 @@ impl OutlinePanel { &mut self, entry: &PanelEntry, prefer_selection_change: bool, - change_focus: bool, + prefer_focus_change: bool, window: &mut Window, cx: &mut Context, ) { @@ -987,9 +996,13 @@ impl OutlinePanel { let active_multi_buffer = active_editor.read(cx).buffer().clone(); let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); let mut change_selection = prefer_selection_change; + let mut change_focus = prefer_focus_change; let mut scroll_to_buffer = None; let scroll_target = match entry { - PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => None, + PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => { + change_focus = false; + None + } PanelEntry::Fs(FsEntry::ExternalFile(file)) => { change_selection = false; scroll_to_buffer = Some(file.buffer_id); @@ -1033,6 +1046,7 @@ impl OutlinePanel { }), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { change_selection = false; + change_focus = false; multi_buffer_snapshot.anchor_in_excerpt(excerpt.id, excerpt.range.context.start) } PanelEntry::Search(search_entry) => Some(search_entry.match_range.start), @@ -2613,7 +2627,7 @@ impl OutlinePanel { .spawn(async move { let mut processed_external_buffers = HashSet::default(); let mut new_worktree_entries = - HashMap::>::default(); + BTreeMap::>::default(); let mut worktree_excerpts = HashMap::< WorktreeId, HashMap)>, @@ -2896,8 +2910,8 @@ impl OutlinePanel { outline_panel.fs_entries = new_fs_entries; outline_panel.fs_entries_depth = new_depth_map; outline_panel.fs_children_count = new_children_count; - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); outline_panel.update_non_fs_items(window, cx); + outline_panel.update_cached_entries(debounce, window, cx); cx.notify(); }) @@ -2922,7 +2936,11 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context| { if matches!(e, SearchEvent::MatchesInvalidated) { - outline_panel.update_search_matches(window, cx); + let update_cached_items = outline_panel.update_search_matches(window, cx); + if update_cached_items { + outline_panel.selected_entry.invalidate(); + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } }; outline_panel.autoscroll(cx); }, @@ -3188,10 +3206,12 @@ impl OutlinePanel { } let syntax_theme = cx.theme().syntax().clone(); + let first_update = Arc::new(AtomicBool::new(true)); for (buffer_id, (buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges { for (excerpt_id, excerpt_range) in excerpt_ranges { let syntax_theme = syntax_theme.clone(); let buffer_snapshot = buffer_snapshot.clone(); + let first_update = first_update.clone(); self.outline_fetch_tasks.insert( (buffer_id, excerpt_id), cx.spawn_in(window, |outline_panel, mut cx| async move { @@ -3215,13 +3235,16 @@ impl OutlinePanel { .or_default() .get_mut(&excerpt_id) { + let debounce = if first_update + .fetch_and(false, atomic::Ordering::AcqRel) + { + None + } else { + Some(UPDATE_DEBOUNCE) + }; excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines); + outline_panel.update_cached_entries(debounce, window, cx); } - outline_panel.update_cached_entries( - Some(UPDATE_DEBOUNCE), - window, - cx, - ); }) .ok(); }), @@ -3376,6 +3399,7 @@ impl OutlinePanel { let is_singleton = self.is_singleton_active(cx); let query = self.query(cx); + self.updating_cached_entries = true; self.cached_entries_update_task = cx.spawn_in(window, |outline_panel, mut cx| async move { if let Some(debounce) = debounce { cx.background_executor().timer(debounce).await; @@ -3410,6 +3434,7 @@ impl OutlinePanel { } outline_panel.autoscroll(cx); + outline_panel.updating_cached_entries = false; cx.notify(); }) .ok(); @@ -3468,7 +3493,8 @@ impl OutlinePanel { .copied() .unwrap_or(0); while let Some(parent) = parent_dirs.last() { - if directory_entry.entry.path.starts_with(&parent.path) { + if !is_root && directory_entry.entry.path.starts_with(&parent.path) + { break; } parent_dirs.pop(); @@ -3915,19 +3941,27 @@ impl OutlinePanel { !self.collapsed_entries.contains(&entry_to_check) } - fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) { + fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) -> bool { if !self.active { - return; + return false; } - self.update_search_matches(window, cx); + let mut update_cached_items = false; + update_cached_items |= self.update_search_matches(window, cx); self.fetch_outdated_outlines(window, cx); - self.autoscroll(cx); + if update_cached_items { + self.selected_entry.invalidate(); + } + update_cached_items } - fn update_search_matches(&mut self, window: &mut Window, cx: &mut Context) { + fn update_search_matches( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { if !self.active { - return; + return false; } let project_search = self @@ -4010,10 +4044,7 @@ impl OutlinePanel { cx, )); } - if update_cached_entries { - self.selected_entry.invalidate(); - self.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + update_cached_entries } #[allow(clippy::too_many_arguments)] @@ -4426,41 +4457,42 @@ impl OutlinePanel { cx: &mut Context, ) -> Div { let contents = if self.cached_entries.is_empty() { - let header = if self.updating_fs_entries { - "Loading outlines" + let header = if self.updating_fs_entries || self.updating_cached_entries { + None } else if query.is_some() { - "No matches for query" + Some("No matches for query") } else { - "No outlines available" + Some("No outlines available") }; v_flex() .flex_1() .justify_center() .size_full() - .child(h_flex().justify_center().child(Label::new(header))) - .when_some(query.clone(), |panel, query| { - panel.child(h_flex().justify_center().child(Label::new(query))) + .when_some(header, |panel, header| { + panel + .child(h_flex().justify_center().child(Label::new(header))) + .when_some(query.clone(), |panel, query| { + panel.child(h_flex().justify_center().child(Label::new(query))) + }) + .child( + h_flex() + .pt(DynamicSpacing::Base04.rems(cx)) + .justify_center() + .child({ + let keystroke = + match self.position(window, cx) { + DockPosition::Left => window + .keystroke_text_for(&workspace::ToggleLeftDock), + DockPosition::Bottom => window + .keystroke_text_for(&workspace::ToggleBottomDock), + DockPosition::Right => window + .keystroke_text_for(&workspace::ToggleRightDock), + }; + Label::new(format!("Toggle this panel with {keystroke}")) + }), + ) }) - .child( - h_flex() - .pt(DynamicSpacing::Base04.rems(cx)) - .justify_center() - .child({ - let keystroke = match self.position(window, cx) { - DockPosition::Left => { - window.keystroke_text_for(&workspace::ToggleLeftDock) - } - DockPosition::Bottom => { - window.keystroke_text_for(&workspace::ToggleBottomDock) - } - DockPosition::Right => { - window.keystroke_text_for(&workspace::ToggleRightDock) - } - }; - Label::new(format!("Toggle this panel with {keystroke}")) - }), - ) } else { let list_contents = { let items_len = self.cached_entries.len(); @@ -4995,11 +5027,17 @@ fn subscribe_for_editor_events( } EditorEvent::ExcerptsExpanded { ids } => { outline_panel.invalidate_outlines(ids); - outline_panel.update_non_fs_items(window, cx); + let update_cached_items = outline_panel.update_non_fs_items(window, cx); + if update_cached_items { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } } EditorEvent::ExcerptsEdited { ids } => { outline_panel.invalidate_outlines(ids); - outline_panel.update_non_fs_items(window, cx); + let update_cached_items = outline_panel.update_non_fs_items(window, cx); + if update_cached_items { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } } EditorEvent::BufferFoldToggled { ids, .. } => { outline_panel.invalidate_outlines(ids); @@ -5073,7 +5111,10 @@ fn subscribe_for_editor_events( excerpt.invalidate_outlines(); } } - outline_panel.update_non_fs_items(window, cx); + let update_cached_items = outline_panel.update_non_fs_items(window, cx); + if update_cached_items { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } } _ => {} } @@ -5117,6 +5158,7 @@ mod tests { use project::FakeFs; use search::project_search::{self, perform_project_search}; use serde_json::json; + use workspace::OpenVisible; use super::*; @@ -5173,7 +5215,7 @@ mod tests { }); }); - let all_matches = r#"/ + let all_matches = r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5208,9 +5250,11 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), select_first_in_all_matches( "search: match config.param_names_for_lifetime_elision_hints {" @@ -5222,9 +5266,11 @@ mod tests { outline_panel.select_parent(&SelectParent, window, cx); assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), select_first_in_all_matches("fn_lifetime_fn.rs") ); @@ -5238,12 +5284,14 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), format!( - r#"/ + r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5273,9 +5321,11 @@ mod tests { outline_panel.select_parent(&SelectParent, window, cx); assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), select_first_in_all_matches("inlay_hints/") ); @@ -5285,9 +5335,11 @@ mod tests { outline_panel.select_parent(&SelectParent, window, cx); assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), select_first_in_all_matches("ide/src/") ); @@ -5302,12 +5354,14 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), format!( - r#"/ + r#"/rust-analyzer/ crates/ ide/src/{SELECTED_MARKER} rust-analyzer/src/ @@ -5328,9 +5382,11 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), select_first_in_all_matches("ide/src/") ); @@ -5387,7 +5443,7 @@ mod tests { ); }); }); - let all_matches = r#"/ + let all_matches = r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5414,9 +5470,11 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, None, + cx, ), all_matches, ); @@ -5435,12 +5493,15 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, None, + cx, ), all_matches .lines() + .skip(1) // `/rust-analyzer/` is a root entry with path `` and it will be filtered out .filter(|item| item.contains(filter_text)) .collect::>() .join("\n"), @@ -5458,9 +5519,11 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, None, + cx, ), all_matches, ); @@ -5517,7 +5580,7 @@ mod tests { ); }); }); - let all_matches = r#"/ + let all_matches = r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5559,9 +5622,11 @@ mod tests { outline_panel.update_in(cx, |outline_panel, window, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), + cx, ), select_first_in_all_matches(initial_outline_selection) ); @@ -5580,9 +5645,11 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), + cx, ), select_first_in_all_matches(navigated_outline_selection) ); @@ -5616,9 +5683,11 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), + cx, ), select_first_in_all_matches(next_navigated_outline_selection) ); @@ -5651,9 +5720,11 @@ mod tests { ); assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), + cx, ), "fn_lifetime_fn.rs <==== selected" ); @@ -5665,6 +5736,176 @@ mod tests { }); } + #[gpui::test] + async fn test_multiple_workrees(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "one": { + "a.txt": "aaa aaa" + }, + "two": { + "b.txt": "a aaa" + } + + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new("/root/one")], cx).await; + let workspace = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let outline_panel = outline_panel(&workspace, cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.set_active(true, window, cx) + }); + + let items = workspace + .update(cx, |workspace, window, cx| { + workspace.open_paths( + vec![PathBuf::from("/root/two")], + OpenVisible::OnlyDirectories, + None, + window, + cx, + ) + }) + .unwrap() + .await; + assert_eq!(items.len(), 1, "Were opening another worktree directory"); + assert!( + items[0].is_none(), + "Directory should be opened successfully" + ); + + workspace + .update(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }) + .unwrap(); + let search_view = workspace + .update(cx, |workspace, _, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }) + .unwrap(); + + let query = "aaa"; + perform_project_search(&search_view, query, cx); + search_view.update(cx, |search_view, cx| { + search_view + .results_editor() + .update(cx, |results_editor, cx| { + assert_eq!( + results_editor.display_text(cx).match_indices(query).count(), + 3 + ); + }); + }); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + r#"/root/one/ + a.txt + search: aaa aaa <==== selected + search: aaa aaa +/root/two/ + b.txt + search: a aaa"# + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_prev(&SelectPrev, window, cx); + outline_panel.open(&Open, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + r#"/root/one/ + a.txt <==== selected +/root/two/ + b.txt + search: a aaa"# + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_next(&SelectNext, window, cx); + outline_panel.open(&Open, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + r#"/root/one/ + a.txt +/root/two/ <==== selected"# + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.open(&Open, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + r#"/root/one/ + a.txt +/root/two/ <==== selected + b.txt + search: a aaa"# + ); + }); + } + #[gpui::test] async fn test_navigating_in_singleton(cx: &mut TestAppContext) { init_test(cx); @@ -5730,9 +5971,11 @@ struct OutlineEntryExcerpt { outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5755,9 +5998,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5780,9 +6025,11 @@ outline: struct OutlineEntryExcerpt <==== selected outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5805,9 +6052,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5830,9 +6079,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5855,9 +6106,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5880,9 +6133,11 @@ outline: struct OutlineEntryExcerpt <==== selected outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5905,9 +6160,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5930,9 +6187,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5955,9 +6214,11 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -5980,9 +6241,11 @@ outline: struct OutlineEntryExcerpt <==== selected outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), indoc!( " @@ -6084,11 +6347,13 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), - r#"/ + r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } <==== selected @@ -6119,11 +6384,13 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), - r#"/ + r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } @@ -6145,11 +6412,13 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), - r#"/ + r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } @@ -6175,11 +6444,13 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), - r#"/ + r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } @@ -6204,11 +6475,13 @@ outline: struct OutlineEntryExcerpt outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( + &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, - outline_panel.selected_entry() + outline_panel.selected_entry(), + cx, ), - r#"/ + r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } @@ -6255,9 +6528,11 @@ outline: struct OutlineEntryExcerpt } fn display_entries( + project: &Entity, multi_buffer_snapshot: &MultiBufferSnapshot, cached_entries: &[CachedEntry], selected_entry: Option<&PanelEntry>, + cx: &mut App, ) -> String { let mut display_string = String::new(); for entry in cached_entries { @@ -6272,15 +6547,33 @@ outline: struct OutlineEntryExcerpt FsEntry::ExternalFile(_) => { panic!("Did not cover external files with tests") } - FsEntry::Directory(directory) => format!( - "{}/", - directory - .entry - .path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default() - ), + FsEntry::Directory(directory) => { + match project + .read(cx) + .worktree_for_id(directory.worktree_id, cx) + .and_then(|worktree| { + if worktree.read(cx).root_entry() == Some(&directory.entry.entry) { + Some(worktree.read(cx).abs_path()) + } else { + None + } + }) { + Some(root_path) => format!( + "{}/{}", + root_path.display(), + directory.entry.path.display(), + ), + None => format!( + "{}/", + directory + .entry + .path + .file_name() + .unwrap_or_default() + .to_string_lossy() + ), + } + } FsEntry::File(file) => file .entry .path diff --git a/crates/panel/Cargo.toml b/crates/panel/Cargo.toml index 4e7c81804d32b329bbc701b5e068777ab24d4a5b..3c51e6d6dcdb31922c07bd1d16923fdd10eeceb7 100644 --- a/crates/panel/Cargo.toml +++ b/crates/panel/Cargo.toml @@ -12,6 +12,9 @@ workspace = true path = "src/panel.rs" [dependencies] +editor.workspace = true gpui.workspace = true +settings.workspace = true +theme.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs index 017a362b0ef15f6bcb7adc9dbeaca7541c62d30a..934d8281a31f7869047dae7d07afe6b60719db29 100644 --- a/crates/panel/src/panel.rs +++ b/crates/panel/src/panel.rs @@ -1,5 +1,8 @@ //! # panel -use gpui::actions; +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{actions, Entity, TextStyle}; +use settings::Settings; +use theme::ThemeSettings; use ui::{prelude::*, Tab}; actions!(panel, [NextPanelTab, PreviousPanelTab]); @@ -46,7 +49,8 @@ pub fn panel_button(label: impl Into) -> ui::Button { let id = ElementId::Name(label.clone().to_lowercase().replace(' ', "_").into()); ui::Button::new(id, label) .label_size(ui::LabelSize::Small) - .layer(ui::ElevationIndex::Surface) + // TODO: Change this once we use on_surface_bg in button_like + .layer(ui::ElevationIndex::ModalSurface) .size(ui::ButtonSize::Compact) } @@ -57,10 +61,65 @@ pub fn panel_filled_button(label: impl Into) -> ui::Button { pub fn panel_icon_button(id: impl Into, icon: IconName) -> ui::IconButton { let id = ElementId::Name(id.into()); ui::IconButton::new(id, icon) - .layer(ui::ElevationIndex::Surface) + // TODO: Change this once we use on_surface_bg in button_like + .layer(ui::ElevationIndex::ModalSurface) .size(ui::ButtonSize::Compact) } pub fn panel_filled_icon_button(id: impl Into, icon: IconName) -> ui::IconButton { panel_icon_button(id, icon).style(ui::ButtonStyle::Filled) } + +pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div { + v_flex() + .size_full() + .gap(px(8.)) + .p_2() + .bg(cx.theme().colors().editor_background) +} + +pub fn panel_editor_style(monospace: bool, window: &mut Window, cx: &mut App) -> EditorStyle { + let settings = ThemeSettings::get_global(cx); + + let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size()); + + let (font_family, font_features, font_weight, line_height) = if monospace { + ( + settings.buffer_font.family.clone(), + settings.buffer_font.features.clone(), + settings.buffer_font.weight, + font_size * settings.buffer_line_height.value(), + ) + } else { + ( + settings.ui_font.family.clone(), + settings.ui_font.features.clone(), + settings.ui_font.weight, + window.line_height(), + ) + }; + + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: TextStyle { + color: cx.theme().colors().text, + font_family, + font_features, + font_size: TextSize::Small.rems(cx).into(), + font_weight, + line_height: line_height.into(), + ..Default::default() + }, + ..Default::default() + } +} + +pub fn panel_editor_element( + editor: &Entity, + monospace: bool, + window: &mut Window, + cx: &mut App, +) -> EditorElement { + EditorElement::new(editor, panel_editor_style(monospace, window, cx)) +} diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index acb541aceabdab6d102158b34898b3d4e0d83502..fa6217ce690a9d471d3d1ef761f4e46e9f891e85 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -145,12 +145,24 @@ pub fn settings_file() -> &'static PathBuf { SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json")) } +/// Returns the path to the `settings_backup.json` file. +pub fn settings_backup_file() -> &'static PathBuf { + static SETTINGS_FILE: OnceLock = OnceLock::new(); + SETTINGS_FILE.get_or_init(|| config_dir().join("settings_backup.json")) +} + /// Returns the path to the `keymap.json` file. pub fn keymap_file() -> &'static PathBuf { static KEYMAP_FILE: OnceLock = OnceLock::new(); KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json")) } +/// Returns the path to the `keymap_backup.json` file. +pub fn keymap_backup_file() -> &'static PathBuf { + static KEYMAP_FILE: OnceLock = OnceLock::new(); + KEYMAP_FILE.get_or_init(|| config_dir().join("keymap_backup.json")) +} + /// Returns the path to the `tasks.json` file. pub fn tasks_file() -> &'static PathBuf { static TASKS_FILE: OnceLock = OnceLock::new(); diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 2c24a63079d9e2e4b19b37f33da335b0c634efdd..debc89b3210e731ad8f94394a8bf2de49c4b2ab2 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -15,7 +15,7 @@ use gpui::{ use language::{Buffer, LanguageRegistry}; use rpc::{proto, AnyProtoClient}; use settings::WorktreeId; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use text::BufferId; use util::{maybe, ResultExt}; @@ -299,19 +299,25 @@ impl Repository { (self.worktree_id, self.repository_entry.work_directory_id()) } + pub fn branch(&self) -> Option> { + self.repository_entry.branch() + } + pub fn display_name(&self, project: &Project, cx: &App) -> SharedString { maybe!({ - let path = self.repo_path_to_project_path(&"".into())?; - Some( - project - .absolute_path(&path, cx)? - .file_name()? - .to_string_lossy() - .to_string() - .into(), - ) + let project_path = self.repo_path_to_project_path(&"".into())?; + let worktree_name = project + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .root_name(); + + let mut path = PathBuf::new(); + path = path.join(worktree_name); + path = path.join(project_path.path); + Some(path.to_string_lossy().to_string()) }) - .unwrap_or("".into()) + .unwrap_or_else(|| self.repository_entry.work_directory.display_name()) + .into() } pub fn activate(&self, cx: &mut Context) { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9d670291b6d7d29649a249b09b83193fb6237115..da2eeb857834dd644407bdd15b240549cafaa072 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1535,6 +1535,10 @@ impl Project { }) } + /// Renames the project entry with given `entry_id`. + /// + /// `new_path` is a relative path to worktree root. + /// If root entry is renamed then its new root name is used instead. pub fn rename_entry( &mut self, entry_id: ProjectEntryId, @@ -1551,12 +1555,18 @@ impl Project { }; let worktree_id = worktree.read(cx).id(); + let is_root_entry = self.entry_is_worktree_root(entry_id, cx); let lsp_store = self.lsp_store().downgrade(); cx.spawn(|_, mut cx| async move { let (old_abs_path, new_abs_path) = { let root_path = worktree.update(&mut cx, |this, _| this.abs_path())?; - (root_path.join(&old_path), root_path.join(&new_path)) + let new_abs_path = if is_root_entry { + root_path.parent().unwrap().join(&new_path) + } else { + root_path.join(&new_path) + }; + (root_path.join(&old_path), new_abs_path) }; LspStore::will_rename_entry( lsp_store.clone(), diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c308e8ca4eacd51ff234dfbac1d8481293d40653..ffdab5bcb0b9c6af5c43b9673bf5f1ec117f2bc8 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -733,7 +733,9 @@ impl ProjectPanel { .action("Copy Path", Box::new(CopyPath)) .action("Copy Relative Path", Box::new(CopyRelativePath)) .separator() - .action("Rename", Box::new(Rename)) + .when(!is_root || !cfg!(target_os = "windows"), |menu| { + menu.action("Rename", Box::new(Rename)) + }) .when(!is_root & !is_remote, |menu| { menu.action("Trash", Box::new(Trash { skip_prompt: false })) }) @@ -1348,6 +1350,10 @@ impl ProjectPanel { if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) { let sub_entry_id = self.unflatten_entry_id(entry_id); if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) { + #[cfg(target_os = "windows")] + if Some(entry) == worktree.read(cx).root_entry() { + return; + } self.edit_state = Some(EditState { worktree_id, entry_id: sub_entry_id, @@ -7280,6 +7286,84 @@ mod tests { ); } + #[gpui::test] + #[cfg_attr(target_os = "windows", ignore)] + async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "dir1": { + "file1.txt": "content 1", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root1/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root1", " v dir1 <== selected", " file1.txt",], + "Initial state with worktrees" + ); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root1 <== selected", " v dir1", " file1.txt",], + ); + + // Rename root1 to new_root1 + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v [EDITOR: 'root1'] <== selected", + " v dir1", + " file1.txt", + ], + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new_root1", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v new_root1 <== selected", + " v dir1", + " file1.txt", + ], + "Should update worktree name" + ); + + // Ensure internal paths have been updated + select_path(&panel, "new_root1/dir1/file1.txt", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v new_root1", + " v dir1", + " file1.txt <== selected", + ], + "Files in renamed worktree are selectable" + ); + } + #[gpui::test] async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 4c00977cf65c7960152c13d7fd53a971beef2739..57ee4cdcefae927802cd35213f8247cbae00cde2 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -2,6 +2,7 @@ use crate::kernels::KernelSpecification; use crate::repl_store::ReplStore; use crate::KERNEL_DOCS_URL; +use gpui::AnyView; use gpui::DismissEvent; use gpui::FontWeight; @@ -19,10 +20,15 @@ use ui::{prelude::*, ListItem, PopoverMenu, PopoverMenuHandle, PopoverTrigger}; type OnSelect = Box; #[derive(IntoElement)] -pub struct KernelSelector { +pub struct KernelSelector +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ handle: Option>>, on_select: OnSelect, trigger: T, + tooltip: TT, info_text: Option, worktree_id: WorktreeId, } @@ -44,12 +50,17 @@ fn truncate_path(path: &SharedString, max_length: usize) -> SharedString { } } -impl KernelSelector { - pub fn new(on_select: OnSelect, worktree_id: WorktreeId, trigger: T) -> Self { +impl KernelSelector +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ + pub fn new(on_select: OnSelect, worktree_id: WorktreeId, trigger: T, tooltip: TT) -> Self { KernelSelector { on_select, handle: None, trigger, + tooltip, info_text: None, worktree_id, } @@ -235,7 +246,11 @@ impl PickerDelegate for KernelPickerDelegate { } } -impl RenderOnce for KernelSelector { +impl RenderOnce for KernelSelector +where + T: PopoverTrigger + ButtonCommon, + TT: Fn(&mut Window, &mut App) -> AnyView + 'static, +{ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let store = ReplStore::global(cx).read(cx); @@ -262,7 +277,7 @@ impl RenderOnce for KernelSelector { PopoverMenu::new("kernel-switcher") .menu(move |_window, _cx| Some(picker_view.clone())) - .trigger(self.trigger) + .trigger_with_tooltip(self.trigger, self.tooltip) .attach(gpui::Corner::BottomLeft) .when_some(self.handle, |menu, handle| menu.with_handle(handle)) } diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 58c7915b915d6971fd0567502a64d7d26b58dd1b..fb967bde3f57b5d730108d72c2e84190b4777363 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -588,24 +588,24 @@ impl KeymapFile { let Some(new_text) = migrate_keymap(&old_text) else { return Ok(()); }; - let initial_path = paths::keymap_file().as_path(); - if fs.is_file(initial_path).await { - let backup_path = paths::home_dir().join(".zed_keymap_backup"); - fs.atomic_write(backup_path, old_text) + let keymap_path = paths::keymap_file().as_path(); + if fs.is_file(keymap_path).await { + fs.atomic_write(paths::keymap_backup_file().to_path_buf(), old_text) .await .with_context(|| { "Failed to create settings backup in home directory".to_string() })?; - let resolved_path = fs.canonicalize(initial_path).await.with_context(|| { - format!("Failed to canonicalize keymap path {:?}", initial_path) - })?; + let resolved_path = fs + .canonicalize(keymap_path) + .await + .with_context(|| format!("Failed to canonicalize keymap path {:?}", keymap_path))?; fs.atomic_write(resolved_path.clone(), new_text) .await .with_context(|| format!("Failed to write keymap to file {:?}", resolved_path))?; } else { - fs.atomic_write(initial_path.to_path_buf(), new_text) + fs.atomic_write(keymap_path.to_path_buf(), new_text) .await - .with_context(|| format!("Failed to write keymap to file {:?}", initial_path))?; + .with_context(|| format!("Failed to write keymap to file {:?}", keymap_path))?; } Ok(()) diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 2337f7fef3d0b1190231bcd020466d9224364391..6f69909b934432155f28d2ad696aa7464b87d516 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -415,11 +415,11 @@ impl SettingsStore { let new_text = cx.read_global(|store: &SettingsStore, cx| { store.new_text_for_update::(old_text, |content| update(content, cx)) })?; - let initial_path = paths::settings_file().as_path(); - if fs.is_file(initial_path).await { + let settings_path = paths::settings_file().as_path(); + if fs.is_file(settings_path).await { let resolved_path = - fs.canonicalize(initial_path).await.with_context(|| { - format!("Failed to canonicalize settings path {:?}", initial_path) + fs.canonicalize(settings_path).await.with_context(|| { + format!("Failed to canonicalize settings path {:?}", settings_path) })?; fs.atomic_write(resolved_path.clone(), new_text) @@ -428,10 +428,10 @@ impl SettingsStore { format!("Failed to write settings to file {:?}", resolved_path) })?; } else { - fs.atomic_write(initial_path.to_path_buf(), new_text) + fs.atomic_write(settings_path.to_path_buf(), new_text) .await .with_context(|| { - format!("Failed to write settings to file {:?}", initial_path) + format!("Failed to write settings to file {:?}", settings_path) })?; } @@ -1011,17 +1011,16 @@ impl SettingsStore { let Some(new_text) = migrate_settings(&old_text) else { return anyhow::Ok(()); }; - let initial_path = paths::settings_file().as_path(); - if fs.is_file(initial_path).await { - let backup_path = paths::home_dir().join(".zed_settings_backup"); - fs.atomic_write(backup_path, old_text) + let settings_path = paths::settings_file().as_path(); + if fs.is_file(settings_path).await { + fs.atomic_write(paths::settings_backup_file().to_path_buf(), old_text) .await .with_context(|| { "Failed to create settings backup in home directory".to_string() })?; let resolved_path = - fs.canonicalize(initial_path).await.with_context(|| { - format!("Failed to canonicalize settings path {:?}", initial_path) + fs.canonicalize(settings_path).await.with_context(|| { + format!("Failed to canonicalize settings path {:?}", settings_path) })?; fs.atomic_write(resolved_path.clone(), new_text) .await @@ -1029,10 +1028,10 @@ impl SettingsStore { format!("Failed to write settings to file {:?}", resolved_path) })?; } else { - fs.atomic_write(initial_path.to_path_buf(), new_text) + fs.atomic_write(settings_path.to_path_buf(), new_text) .await .with_context(|| { - format!("Failed to write settings to file {:?}", initial_path) + format!("Failed to write settings to file {:?}", settings_path) })?; } anyhow::Ok(()) diff --git a/crates/storybook/src/story_selector.rs b/crates/storybook/src/story_selector.rs index f9af57f8b52ae64e521bf19ab55fd4b30e75bd93..6f844a65b5808cb990d9c79af2e3f6609220b32f 100644 --- a/crates/storybook/src/story_selector.rs +++ b/crates/storybook/src/story_selector.rs @@ -36,7 +36,6 @@ pub enum ComponentStory { TabBar, Text, ToggleButton, - ToolStrip, ViewportUnits, WithRemSize, Vector, @@ -73,7 +72,6 @@ impl ComponentStory { Self::TabBar => cx.new(|_| ui::TabBarStory).into(), Self::Text => TextStory::model(cx).into(), Self::ToggleButton => cx.new(|_| ui::ToggleButtonStory).into(), - Self::ToolStrip => cx.new(|_| ui::ToolStripStory).into(), Self::ViewportUnits => cx.new(|_| crate::stories::ViewportUnitsStory).into(), Self::WithRemSize => cx.new(|_| crate::stories::WithRemSizeStory).into(), Self::Vector => cx.new(|_| ui::VectorStory).into(), diff --git a/crates/supermaven/src/supermaven_completion_provider.rs b/crates/supermaven/src/supermaven_completion_provider.rs index 3e70a1c57672e1e6908404ce5ff95ca55f02be0e..4dc0ebbabbed6d9d16fa765c27c189de82fca8dd 100644 --- a/crates/supermaven/src/supermaven_completion_provider.rs +++ b/crates/supermaven/src/supermaven_completion_provider.rs @@ -92,6 +92,7 @@ fn completion_from_diff( } InlineCompletion { + id: None, edits, edit_preview: None, } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 14a3e111b304a8911f5dad0a3b47b0c028a1dc85..af19555fe907fa2b894818b1af1b5a481f948f10 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -139,10 +139,9 @@ impl TerminalPanel { .gap(DynamicSpacing::Base02.rems(cx)) .child( PopoverMenu::new("terminal-tab-bar-popover-menu") - .trigger( - IconButton::new("plus", IconName::Plus) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("New…")), + .trigger_with_tooltip( + IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small), + Tooltip::text("New…"), ) .anchor(Corner::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) @@ -169,10 +168,10 @@ impl TerminalPanel { .children(assistant_tab_bar_button.clone()) .child( PopoverMenu::new("terminal-pane-tab-bar-split") - .trigger( + .trigger_with_tooltip( IconButton::new("terminal-pane-split", IconName::Split) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Split Pane")), + .icon_size(IconSize::Small), + Tooltip::text("Split Pane"), ) .anchor(Corner::TopRight) .with_handle(pane.split_item_context_menu_handle.clone()) diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 955550596d69d31b1f50f17bc63bccd3d75147f1..dec281b47224bff1a9d98f3e0ac6ac905b2778a2 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -133,16 +133,14 @@ impl ApplicationMenu { .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) - .trigger( + .trigger_with_tooltip( IconButton::new( SharedString::from(format!("{}-menu-trigger", menu_name)), ui::IconName::Menu, ) .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .when(!handle.is_deployed(), |this| { - this.tooltip(Tooltip::text("Open Application Menu")) - }), + .icon_size(IconSize::Small), + Tooltip::text("Open Application Menu"), ) .with_handle(handle), ) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 801e701e785ab3ca5f95b2950d72d7fad7737ec2..9f430585c41656afcb319b4dfd216b8f3b02fafb 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -530,7 +530,7 @@ impl TitleBar { .tooltip(move |window, cx| { Tooltip::with_meta( "Recent Branches", - Some(&zed_actions::branches::OpenRecent), + Some(&zed_actions::git::Branch), "Local branches only", window, cx, @@ -538,7 +538,7 @@ impl TitleBar { }) .on_click(move |_, window, cx| { let _ = workspace.update(cx, |_this, cx| { - window.dispatch_action(zed_actions::branches::OpenRecent.boxed_clone(), cx); + window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); }); }), ) @@ -673,6 +673,10 @@ impl TitleBar { "Themes…", zed_actions::theme_selector::Toggle::default().boxed_clone(), ) + .action( + "Icon Themes…", + zed_actions::icon_theme_selector::Toggle::default().boxed_clone(), + ) .action("Extensions", zed_actions::Extensions.boxed_clone()) .separator() .link( @@ -686,7 +690,7 @@ impl TitleBar { }) .into() }) - .trigger( + .trigger_with_tooltip( ButtonLike::new("user-menu") .child( h_flex() @@ -702,8 +706,8 @@ impl TitleBar { .color(Color::Muted), ), ) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("Toggle User Menu")), + .style(ButtonStyle::Subtle), + Tooltip::text("Toggle User Menu"), ) .anchor(gpui::Corner::TopRight) } else { @@ -716,6 +720,10 @@ impl TitleBar { "Themes…", zed_actions::theme_selector::Toggle::default().boxed_clone(), ) + .action( + "Icon Themes…", + zed_actions::icon_theme_selector::Toggle::default().boxed_clone(), + ) .action("Extensions", zed_actions::Extensions.boxed_clone()) .separator() .link( @@ -728,10 +736,9 @@ impl TitleBar { }) .into() }) - .trigger( - IconButton::new("user-menu", IconName::ChevronDown) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Toggle User Menu")), + .trigger_with_tooltip( + IconButton::new("user-menu", IconName::ChevronDown).icon_size(IconSize::Small), + Tooltip::text("Toggle User Menu"), ) } } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index dc893d66439f4e48d8bb7372355ad877ee7e821c..ba7c89a8a6450b12d7ed3ac0f40b786107863b3d 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -14,8 +14,10 @@ path = "src/ui.rs" [dependencies] chrono.workspace = true +component.workspace = true gpui.workspace = true itertools = { workspace = true, optional = true } +linkme.workspace = true menu.workspace = true serde.workspace = true settings.workspace = true @@ -31,3 +33,7 @@ windows.workspace = true [features] default = [] stories = ["dep:itertools", "dep:story"] + +# cargo-machete doesn't understand that linkme is used in the component macro +[package.metadata.cargo-machete] +ignored = ["linkme"] diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 94ace5632c664bbd04dc2fa7be58b3c2dff2bcc0..184d841bb1caf023b8f1989fd091896d43be5d17 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -29,7 +29,6 @@ mod tab; mod tab_bar; mod table; mod toggle; -mod tool_strip; mod tooltip; #[cfg(feature = "stories")] @@ -66,7 +65,6 @@ pub use tab::*; pub use tab_bar::*; pub use table::*; pub use toggle::*; -pub use tool_strip::*; pub use tooltip::*; #[cfg(feature = "stories")] diff --git a/crates/ui/src/components/avatar/avatar.rs b/crates/ui/src/components/avatar/avatar.rs index e7335a9e75a4b52bb14f322ce53432136537de88..82f3ea7ae2e4764471c8eb0df67827666848640b 100644 --- a/crates/ui/src/components/avatar/avatar.rs +++ b/crates/ui/src/components/avatar/avatar.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{prelude::*, Indicator}; use gpui::{img, AnyElement, Hsla, ImageSource, Img, IntoElement, Styled}; @@ -14,7 +14,7 @@ use gpui::{img, AnyElement, Hsla, ImageSource, Img, IntoElement, Styled}; /// .grayscale(true) /// .border_color(gpui::red()); /// ``` -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Avatar { image: Img, size: Option, @@ -96,3 +96,60 @@ impl RenderOnce for Avatar { .children(self.indicator.map(|indicator| div().child(indicator))) } } + +impl ComponentPreview for Avatar { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + let example_avatar = "https://avatars.githubusercontent.com/u/1714999?v=4"; + + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Sizes", + vec![ + single_example( + "Default", + Avatar::new("https://avatars.githubusercontent.com/u/1714999?v=4") + .into_any_element(), + ), + single_example( + "Small", + Avatar::new(example_avatar).size(px(24.)).into_any_element(), + ), + single_example( + "Large", + Avatar::new(example_avatar).size(px(48.)).into_any_element(), + ), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example("Default", Avatar::new(example_avatar).into_any_element()), + single_example( + "Grayscale", + Avatar::new(example_avatar) + .grayscale(true) + .into_any_element(), + ), + single_example( + "With Border", + Avatar::new(example_avatar) + .border_color(gpui::red()) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Indicator", + vec![single_example( + "Dot", + Avatar::new(example_avatar) + .indicator(Indicator::dot().color(Color::Success)) + .into_any_element(), + )], + ), + ]) + .into_any_element() + } +} diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index c9b61866617731191a53cb03ca7ef5e470ecb1cf..0209fd3d17ccf9c66ee8f565eb60d38270f404e5 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -1,5 +1,7 @@ #![allow(missing_docs)] -use gpui::{AnyView, DefiniteLength}; +use component::{example_group_with_title, single_example, ComponentPreview}; +use gpui::{AnyElement, AnyView, DefiniteLength}; +use ui_macros::IntoComponent; use crate::{ prelude::*, Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, @@ -78,7 +80,7 @@ use super::button_icon::ButtonIcon; /// }); /// ``` /// -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Button { base: ButtonLike, label: SharedString, @@ -93,7 +95,7 @@ pub struct Button { selected_icon: Option, selected_icon_color: Option, key_binding: Option, - keybinding_position: KeybindingPosition, + key_binding_position: KeybindingPosition, alpha: Option, } @@ -119,7 +121,7 @@ impl Button { selected_icon: None, selected_icon_color: None, key_binding: None, - keybinding_position: KeybindingPosition::default(), + key_binding_position: KeybindingPosition::default(), alpha: None, } } @@ -195,7 +197,7 @@ impl Button { /// This method allows you to specify where the keybinding should be displayed /// in relation to the button's label. pub fn key_binding_position(mut self, position: KeybindingPosition) -> Self { - self.keybinding_position = position; + self.key_binding_position = position; self } @@ -425,7 +427,7 @@ impl RenderOnce for Button { .child( h_flex() .when( - self.keybinding_position == KeybindingPosition::Start, + self.key_binding_position == KeybindingPosition::Start, |this| this.flex_row_reverse(), ) .gap(DynamicSpacing::Base06.rems(cx)) @@ -455,101 +457,124 @@ impl RenderOnce for Button { } impl ComponentPreview for Button { - fn description() -> impl Into> { - "A button allows users to take actions, and make choices, with a single tap." - } - - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - vec![ - example_group_with_title( - "Styles", - vec![ - single_example("Default", Button::new("default", "Default")), - single_example( - "Filled", - Button::new("filled", "Filled").style(ButtonStyle::Filled), - ), - single_example( - "Subtle", - Button::new("outline", "Subtle").style(ButtonStyle::Subtle), - ), - single_example( - "Transparent", - Button::new("transparent", "Transparent").style(ButtonStyle::Transparent), - ), - ], - ), - example_group_with_title( - "Tinted", - vec![ - single_example( - "Accent", - Button::new("tinted_accent", "Accent") - .style(ButtonStyle::Tinted(TintColor::Accent)), - ), - single_example( - "Error", - Button::new("tinted_negative", "Error") - .style(ButtonStyle::Tinted(TintColor::Error)), - ), - single_example( - "Warning", - Button::new("tinted_warning", "Warning") - .style(ButtonStyle::Tinted(TintColor::Warning)), - ), - single_example( - "Success", - Button::new("tinted_positive", "Success") - .style(ButtonStyle::Tinted(TintColor::Success)), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example("Default", Button::new("default_state", "Default")), - single_example( - "Disabled", - Button::new("disabled", "Disabled").disabled(true), - ), - single_example( - "Selected", - Button::new("selected", "Selected").toggle_state(true), - ), - ], - ), - example_group_with_title( - "With Icons", - vec![ - single_example( - "Icon Start", - Button::new("icon_start", "Icon Start") - .icon(IconName::Check) - .icon_position(IconPosition::Start), - ), - single_example( - "Icon End", - Button::new("icon_end", "Icon End") - .icon(IconName::Check) - .icon_position(IconPosition::End), - ), - single_example( - "Icon Color", - Button::new("icon_color", "Icon Color") - .icon(IconName::Check) - .icon_color(Color::Accent), - ), - single_example( - "Tinted Icons", - Button::new("tinted_icons", "Error") - .style(ButtonStyle::Tinted(TintColor::Error)) - .color(Color::Error) - .icon_color(Color::Error) - .icon(IconName::Trash) - .icon_position(IconPosition::Start), - ), - ], - ), - ] + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Styles", + vec![ + single_example( + "Default", + Button::new("default", "Default").into_any_element(), + ), + single_example( + "Filled", + Button::new("filled", "Filled") + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Subtle", + Button::new("outline", "Subtle") + .style(ButtonStyle::Subtle) + .into_any_element(), + ), + single_example( + "Transparent", + Button::new("transparent", "Transparent") + .style(ButtonStyle::Transparent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Tinted", + vec![ + single_example( + "Accent", + Button::new("tinted_accent", "Accent") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Error", + Button::new("tinted_negative", "Error") + .style(ButtonStyle::Tinted(TintColor::Error)) + .into_any_element(), + ), + single_example( + "Warning", + Button::new("tinted_warning", "Warning") + .style(ButtonStyle::Tinted(TintColor::Warning)) + .into_any_element(), + ), + single_example( + "Success", + Button::new("tinted_positive", "Success") + .style(ButtonStyle::Tinted(TintColor::Success)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Default", + Button::new("default_state", "Default").into_any_element(), + ), + single_example( + "Disabled", + Button::new("disabled", "Disabled") + .disabled(true) + .into_any_element(), + ), + single_example( + "Selected", + Button::new("selected", "Selected") + .toggle_state(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Icons", + vec![ + single_example( + "Icon Start", + Button::new("icon_start", "Icon Start") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .into_any_element(), + ), + single_example( + "Icon End", + Button::new("icon_end", "Icon End") + .icon(IconName::Check) + .icon_position(IconPosition::End) + .into_any_element(), + ), + single_example( + "Icon Color", + Button::new("icon_color", "Icon Color") + .icon(IconName::Check) + .icon_color(Color::Accent) + .into_any_element(), + ), + single_example( + "Tinted Icons", + Button::new("tinted_icons", "Error") + .style(ButtonStyle::Tinted(TintColor::Error)) + .color(Color::Error) + .icon_color(Color::Error) + .icon(IconName::Trash) + .icon_position(IconPosition::Start) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 0b78be078669aeffc8d44342cc0a34a250a756b4..96d093c249ab4a37955f00728cb8c3607877e80c 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -506,7 +506,9 @@ impl RenderOnce for ButtonLike { .group("") .flex_none() .h(self.height.unwrap_or(self.size.rems().into())) - .when_some(self.width, |this, width| this.w(width).justify_center()) + .when_some(self.width, |this, width| { + this.w(width).justify_center().text_center() + }) .when_some(self.rounding, |this, rounding| match rounding { ButtonLikeRounding::All => this.rounded_md(), ButtonLikeRounding::Left => this.rounded_l_md(), diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index c28c5ae9ac0dc1ff2f4e678a9b82b23a017b1cff..204ea8e564c8889fa7d7b36783dbae7bfb934f1c 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -22,6 +22,7 @@ pub struct IconButton { icon_size: IconSize, icon_color: Color, selected_icon: Option, + selected_icon_color: Option, indicator: Option, indicator_border_color: Option, alpha: Option, @@ -36,6 +37,7 @@ impl IconButton { icon_size: IconSize::default(), icon_color: Color::Default, selected_icon: None, + selected_icon_color: None, indicator: None, indicator_border_color: None, alpha: None, @@ -69,6 +71,12 @@ impl IconButton { self } + /// Sets the icon color used when the button is in a selected state. + pub fn selected_icon_color(mut self, color: impl Into>) -> Self { + self.selected_icon_color = color.into(); + self + } + pub fn indicator(mut self, indicator: Indicator) -> Self { self.indicator = Some(indicator); self @@ -181,6 +189,7 @@ impl RenderOnce for IconButton { .disabled(is_disabled) .toggle_state(is_selected) .selected_icon(self.selected_icon) + .selected_icon_color(self.selected_icon_color) .when_some(selected_style, |this, style| this.selected_style(style)) .when_some(self.indicator, |this, indicator| { this.indicator(indicator) diff --git a/crates/ui/src/components/content_group.rs b/crates/ui/src/components/content_group.rs index b1ffae1490cbddc45ee97f2dbc0067ef95f1d29f..1a57838c2e8102234f18f9241edc2349d8fb724a 100644 --- a/crates/ui/src/components/content_group.rs +++ b/crates/ui/src/components/content_group.rs @@ -1,4 +1,5 @@ use crate::prelude::*; +use component::{example_group, single_example, ComponentPreview}; use gpui::{AnyElement, IntoElement, ParentElement, StyleRefinement, Styled}; use smallvec::SmallVec; @@ -22,7 +23,8 @@ pub fn h_group() -> ContentGroup { } /// A flexible container component that can hold other elements. -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] +#[component(scope = "layout")] pub struct ContentGroup { base: Div, border: bool, @@ -87,16 +89,8 @@ impl RenderOnce for ContentGroup { } impl ComponentPreview for ContentGroup { - fn description() -> impl Into> { - "A flexible container component that can hold other elements. It can be customized with or without a border and background fill." - } - - fn example_label_side() -> ExampleLabelSide { - ExampleLabelSide::Bottom - } - - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - vec![example_group(vec![ + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + example_group(vec![ single_example( "Default", ContentGroup::new() @@ -104,7 +98,8 @@ impl ComponentPreview for ContentGroup { .items_center() .justify_center() .h_48() - .child(Label::new("Default ContentBox")), + .child(Label::new("Default ContentBox")) + .into_any_element(), ) .grow(), single_example( @@ -115,7 +110,8 @@ impl ComponentPreview for ContentGroup { .justify_center() .h_48() .borderless() - .child(Label::new("Borderless ContentBox")), + .child(Label::new("Borderless ContentBox")) + .into_any_element(), ) .grow(), single_example( @@ -126,10 +122,11 @@ impl ComponentPreview for ContentGroup { .justify_center() .h_48() .unfilled() - .child(Label::new("Unfilled ContentBox")), + .child(Label::new("Unfilled ContentBox")) + .into_any_element(), ) .grow(), ]) - .grow()] + .into_any_element() } } diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index db9632d4ff31e36195c5216f0820d40c512ae47d..b827a576673f7ffefc4421c7791237fcce13679f 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -672,14 +672,16 @@ impl Render for ContextMenu { *toggle, |list_item, (position, toggled)| { let contents = if toggled { - v_flex().flex_none().child( + div().flex_none().child( Icon::new(IconName::Check) - .color(Color::Accent), + .color(Color::Accent) + .size(*icon_size) ) } else { - v_flex().flex_none().size( - IconSize::default().rems(), - ) + div().flex_none().child( + Icon::new(IconName::Check) + .size(*icon_size) + ).invisible() }; match position { IconPosition::Start => { diff --git a/crates/ui/src/components/facepile.rs b/crates/ui/src/components/facepile.rs index 875b1dfb2aed1ae580cda216716c5b4ebeac22b8..d965bc598a457b777e787f219b0702aa5fe21074 100644 --- a/crates/ui/src/components/facepile.rs +++ b/crates/ui/src/components/facepile.rs @@ -1,4 +1,4 @@ -use crate::{prelude::*, Avatar}; +use crate::prelude::*; use gpui::{AnyElement, StyleRefinement}; use smallvec::SmallVec; @@ -60,60 +60,60 @@ impl RenderOnce for Facepile { } } -impl ComponentPreview for Facepile { - fn description() -> impl Into> { - "A facepile is a collection of faces stacked horizontally–\ - always with the leftmost face on top and descending in z-index.\ - \n\nFacepiles are used to display a group of people or things,\ - such as a list of participants in a collaboration session." - } - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - let few_faces: [&'static str; 3] = [ - "https://avatars.githubusercontent.com/u/1714999?s=60&v=4", - "https://avatars.githubusercontent.com/u/67129314?s=60&v=4", - "https://avatars.githubusercontent.com/u/482957?s=60&v=4", - ]; +// impl ComponentPreview for Facepile { +// fn description() -> impl Into> { +// "A facepile is a collection of faces stacked horizontally–\ +// always with the leftmost face on top and descending in z-index.\ +// \n\nFacepiles are used to display a group of people or things,\ +// such as a list of participants in a collaboration session." +// } +// fn examples(_window: &mut Window, _: &mut App) -> Vec> { +// let few_faces: [&'static str; 3] = [ +// "https://avatars.githubusercontent.com/u/1714999?s=60&v=4", +// "https://avatars.githubusercontent.com/u/67129314?s=60&v=4", +// "https://avatars.githubusercontent.com/u/482957?s=60&v=4", +// ]; - let many_faces: [&'static str; 6] = [ - "https://avatars.githubusercontent.com/u/326587?s=60&v=4", - "https://avatars.githubusercontent.com/u/2280405?s=60&v=4", - "https://avatars.githubusercontent.com/u/1789?s=60&v=4", - "https://avatars.githubusercontent.com/u/67129314?s=60&v=4", - "https://avatars.githubusercontent.com/u/482957?s=60&v=4", - "https://avatars.githubusercontent.com/u/1714999?s=60&v=4", - ]; +// let many_faces: [&'static str; 6] = [ +// "https://avatars.githubusercontent.com/u/326587?s=60&v=4", +// "https://avatars.githubusercontent.com/u/2280405?s=60&v=4", +// "https://avatars.githubusercontent.com/u/1789?s=60&v=4", +// "https://avatars.githubusercontent.com/u/67129314?s=60&v=4", +// "https://avatars.githubusercontent.com/u/482957?s=60&v=4", +// "https://avatars.githubusercontent.com/u/1714999?s=60&v=4", +// ]; - vec![example_group_with_title( - "Examples", - vec![ - single_example( - "Few Faces", - Facepile::new( - few_faces - .iter() - .map(|&url| Avatar::new(url).into_any_element()) - .collect(), - ), - ), - single_example( - "Many Faces", - Facepile::new( - many_faces - .iter() - .map(|&url| Avatar::new(url).into_any_element()) - .collect(), - ), - ), - single_example( - "Custom Size", - Facepile::new( - few_faces - .iter() - .map(|&url| Avatar::new(url).size(px(24.)).into_any_element()) - .collect(), - ), - ), - ], - )] - } -} +// vec![example_group_with_title( +// "Examples", +// vec![ +// single_example( +// "Few Faces", +// Facepile::new( +// few_faces +// .iter() +// .map(|&url| Avatar::new(url).into_any_element()) +// .collect(), +// ), +// ), +// single_example( +// "Many Faces", +// Facepile::new( +// many_faces +// .iter() +// .map(|&url| Avatar::new(url).into_any_element()) +// .collect(), +// ), +// ), +// single_example( +// "Custom Size", +// Facepile::new( +// few_faces +// .iter() +// .map(|&url| Avatar::new(url).size(px(24.)).into_any_element()) +// .collect(), +// ), +// ), +// ], +// )] +// } +// } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 12346026e81000cc820932ec668b56d10369f52f..c23c41cbcf7119b2c368857b8ffe953c6f445b3d 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -7,17 +7,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; pub use decorated_icon::*; -use gpui::{img, svg, AnimationElement, Hsla, IntoElement, Rems, Transformation}; +use gpui::{img, svg, AnimationElement, AnyElement, Hsla, IntoElement, Rems, Transformation}; pub use icon_decoration::*; use serde::{Deserialize, Serialize}; use strum::{EnumIter, EnumString, IntoStaticStr}; use ui_macros::DerivePathStr; -use crate::{ - prelude::*, - traits::component_preview::{ComponentExample, ComponentPreview}, - Indicator, -}; +use crate::{prelude::*, Indicator}; #[derive(IntoElement)] pub enum AnyIcon { @@ -234,6 +230,7 @@ pub enum IconName { Link, ListTree, ListX, + LockOutlined, MagnifyingGlass, MailOpen, Maximize, @@ -363,7 +360,7 @@ impl IconSource { } } -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Icon { source: IconSource, color: Color, @@ -493,24 +490,41 @@ impl RenderOnce for IconWithIndicator { } impl ComponentPreview for Icon { - fn examples(_window: &mut Window, _cx: &mut App) -> Vec> { - let arrow_icons = vec![ - IconName::ArrowDown, - IconName::ArrowLeft, - IconName::ArrowRight, - IconName::ArrowUp, - IconName::ArrowCircle, - ]; - - vec![example_group_with_title( - "Arrow Icons", - arrow_icons - .into_iter() - .map(|icon| { - let name = format!("{:?}", icon).to_string(); - ComponentExample::new(name, Icon::new(icon)) - }) - .collect(), - )] + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Sizes", + vec![ + single_example("Default", Icon::new(IconName::Star).into_any_element()), + single_example( + "Small", + Icon::new(IconName::Star) + .size(IconSize::Small) + .into_any_element(), + ), + single_example( + "Large", + Icon::new(IconName::Star) + .size(IconSize::XLarge) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Colors", + vec![ + single_example("Default", Icon::new(IconName::Bell).into_any_element()), + single_example( + "Custom Color", + Icon::new(IconName::Bell) + .color(Color::Error) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/icon/decorated_icon.rs b/crates/ui/src/components/icon/decorated_icon.rs index 1a441bf6eae6011a49560fadc2ea93ae22d252f9..c973dc60961103aff4dd31286a82303d7496757b 100644 --- a/crates/ui/src/components/icon/decorated_icon.rs +++ b/crates/ui/src/components/icon/decorated_icon.rs @@ -1,10 +1,8 @@ -use gpui::{IntoElement, Point}; +use gpui::{AnyElement, IntoElement, Point}; -use crate::{ - prelude::*, traits::component_preview::ComponentPreview, IconDecoration, IconDecorationKind, -}; +use crate::{prelude::*, IconDecoration, IconDecorationKind}; -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct DecoratedIcon { icon: Icon, decoration: Option, @@ -27,12 +25,7 @@ impl RenderOnce for DecoratedIcon { } impl ComponentPreview for DecoratedIcon { - fn examples(_: &mut Window, cx: &mut App) -> Vec> { - let icon_1 = Icon::new(IconName::FileDoc); - let icon_2 = Icon::new(IconName::FileDoc); - let icon_3 = Icon::new(IconName::FileDoc); - let icon_4 = Icon::new(IconName::FileDoc); - + fn preview(_window: &mut Window, cx: &App) -> AnyElement { let decoration_x = IconDecoration::new( IconDecorationKind::X, cx.theme().colors().surface_background, @@ -66,22 +59,32 @@ impl ComponentPreview for DecoratedIcon { y: px(-2.), }); - let examples = vec![ - single_example("no_decoration", DecoratedIcon::new(icon_1, None)), - single_example( - "with_decoration", - DecoratedIcon::new(icon_2, Some(decoration_x)), - ), - single_example( - "with_decoration", - DecoratedIcon::new(icon_3, Some(decoration_triangle)), - ), - single_example( - "with_decoration", - DecoratedIcon::new(icon_4, Some(decoration_dot)), - ), - ]; - - vec![example_group(examples)] + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Decorations", + vec![ + single_example( + "No Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), None).into_any_element(), + ), + single_example( + "X Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_x)) + .into_any_element(), + ), + single_example( + "Triangle Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_triangle)) + .into_any_element(), + ), + single_example( + "Dot Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_dot)) + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/icon/icon_decoration.rs b/crates/ui/src/components/icon/icon_decoration.rs index 75a04265f9be1dda9c363b21e61c9bb471de12bc..ba73e5a2cb36b656fa9596ba409382ba2371a9fb 100644 --- a/crates/ui/src/components/icon/icon_decoration.rs +++ b/crates/ui/src/components/icon/icon_decoration.rs @@ -1,8 +1,8 @@ use gpui::{svg, Hsla, IntoElement, Point}; -use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr}; +use strum::{EnumIter, EnumString, IntoStaticStr}; use ui_macros::DerivePathStr; -use crate::{prelude::*, traits::component_preview::ComponentPreview}; +use crate::prelude::*; const ICON_DECORATION_SIZE: Pixels = px(11.); @@ -149,21 +149,3 @@ impl RenderOnce for IconDecoration { .child(background) } } - -impl ComponentPreview for IconDecoration { - fn examples(_: &mut Window, cx: &mut App) -> Vec> { - let all_kinds = IconDecorationKind::iter().collect::>(); - - let examples = all_kinds - .iter() - .map(|kind| { - single_example( - format!("{kind:?}"), - IconDecoration::new(*kind, cx.theme().colors().surface_background, cx), - ) - }) - .collect(); - - vec![example_group(examples)] - } -} diff --git a/crates/ui/src/components/indicator.rs b/crates/ui/src/components/indicator.rs index bb275cd9410e76175e3046b159423a75dc46646e..0cf4cab72eba998be7bf47f68d0c95ef704ba538 100644 --- a/crates/ui/src/components/indicator.rs +++ b/crates/ui/src/components/indicator.rs @@ -83,34 +83,3 @@ impl RenderOnce for Indicator { } } } - -impl ComponentPreview for Indicator { - fn description() -> impl Into> { - "An indicator visually represents a status or state." - } - - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - vec![ - example_group_with_title( - "Types", - vec![ - single_example("Dot", Indicator::dot().color(Color::Info)), - single_example("Bar", Indicator::bar().color(Color::Player(2))), - single_example( - "Icon", - Indicator::icon(Icon::new(IconName::Check).color(Color::Success)), - ), - ], - ), - example_group_with_title( - "Examples", - vec![ - single_example("Info", Indicator::dot().color(Color::Info)), - single_example("Success", Indicator::dot().color(Color::Success)), - single_example("Warning", Indicator::dot().color(Color::Warning)), - single_example("Error", Indicator::dot().color(Color::Error)), - ], - ), - ] - } -} diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index 2239cf0790608e5fb7953a0fb631543ad11147ec..2abb93ea40b890568a6b4aa9517a800c8fd72035 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -1,6 +1,6 @@ use crate::{h_flex, prelude::*}; use crate::{ElevationIndex, KeyBinding}; -use gpui::{point, App, BoxShadow, IntoElement, Window}; +use gpui::{point, AnyElement, App, BoxShadow, IntoElement, Window}; use smallvec::smallvec; /// Represents a hint for a keybinding, optionally with a prefix and suffix. @@ -17,7 +17,7 @@ use smallvec::smallvec; /// .prefix("Save:") /// .size(Pixels::from(14.0)); /// ``` -#[derive(Debug, IntoElement, Clone)] +#[derive(Debug, IntoElement, IntoComponent)] pub struct KeybindingHint { prefix: Option, suffix: Option, @@ -206,102 +206,99 @@ impl RenderOnce for KeybindingHint { } impl ComponentPreview for KeybindingHint { - fn description() -> impl Into> { - "Used to display hint text for keyboard shortcuts. Can have a prefix and suffix." - } - - fn examples(window: &mut Window, _cx: &mut App) -> Vec> { - let home_fallback = gpui::KeyBinding::new("home", menu::SelectFirst, None); - let home = KeyBinding::for_action(&menu::SelectFirst, window) - .unwrap_or(KeyBinding::new(home_fallback)); - - let end_fallback = gpui::KeyBinding::new("end", menu::SelectLast, None); - let end = KeyBinding::for_action(&menu::SelectLast, window) - .unwrap_or(KeyBinding::new(end_fallback)); - + fn preview(window: &mut Window, _cx: &App) -> AnyElement { let enter_fallback = gpui::KeyBinding::new("enter", menu::Confirm, None); let enter = KeyBinding::for_action(&menu::Confirm, window) .unwrap_or(KeyBinding::new(enter_fallback)); - let escape_fallback = gpui::KeyBinding::new("escape", menu::Cancel, None); - let escape = KeyBinding::for_action(&menu::Cancel, window) - .unwrap_or(KeyBinding::new(escape_fallback)); - - vec![ - example_group_with_title( - "Basic", - vec![ - single_example( - "With Prefix", - KeybindingHint::with_prefix("Go to Start:", home.clone()), - ), - single_example( - "With Suffix", - KeybindingHint::with_suffix(end.clone(), "Go to End"), - ), - single_example( - "With Prefix and Suffix", - KeybindingHint::new(enter.clone()) - .prefix("Confirm:") - .suffix("Execute selected action"), - ), - ], - ), - example_group_with_title( - "Sizes", - vec![ - single_example( - "Small", - KeybindingHint::new(home.clone()) - .size(Pixels::from(12.0)) - .prefix("Small:"), - ), - single_example( - "Medium", - KeybindingHint::new(end.clone()) - .size(Pixels::from(16.0)) - .suffix("Medium"), - ), - single_example( - "Large", - KeybindingHint::new(enter.clone()) - .size(Pixels::from(20.0)) - .prefix("Large:") - .suffix("Size"), - ), - ], - ), - example_group_with_title( - "Elevations", - vec![ - single_example( - "Surface", - KeybindingHint::new(home.clone()) - .elevation(ElevationIndex::Surface) - .prefix("Surface:"), - ), - single_example( - "Elevated Surface", - KeybindingHint::new(end.clone()) - .elevation(ElevationIndex::ElevatedSurface) - .suffix("Elevated"), - ), - single_example( - "Editor Surface", - KeybindingHint::new(enter.clone()) - .elevation(ElevationIndex::EditorSurface) - .prefix("Editor:") - .suffix("Surface"), - ), - single_example( - "Modal Surface", - KeybindingHint::new(escape.clone()) - .elevation(ElevationIndex::ModalSurface) - .prefix("Modal:") - .suffix("Escape"), - ), - ], - ), - ] + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic", + vec![ + single_example( + "With Prefix", + KeybindingHint::with_prefix("Go to Start:", enter.clone()) + .into_any_element(), + ), + single_example( + "With Suffix", + KeybindingHint::with_suffix(enter.clone(), "Go to End") + .into_any_element(), + ), + single_example( + "With Prefix and Suffix", + KeybindingHint::new(enter.clone()) + .prefix("Confirm:") + .suffix("Execute selected action") + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Sizes", + vec![ + single_example( + "Small", + KeybindingHint::new(enter.clone()) + .size(Pixels::from(12.0)) + .prefix("Small:") + .into_any_element(), + ), + single_example( + "Medium", + KeybindingHint::new(enter.clone()) + .size(Pixels::from(16.0)) + .suffix("Medium") + .into_any_element(), + ), + single_example( + "Large", + KeybindingHint::new(enter.clone()) + .size(Pixels::from(20.0)) + .prefix("Large:") + .suffix("Size") + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Elevations", + vec![ + single_example( + "Surface", + KeybindingHint::new(enter.clone()) + .elevation(ElevationIndex::Surface) + .prefix("Surface:") + .into_any_element(), + ), + single_example( + "Elevated Surface", + KeybindingHint::new(enter.clone()) + .elevation(ElevationIndex::ElevatedSurface) + .suffix("Elevated") + .into_any_element(), + ), + single_example( + "Editor Surface", + KeybindingHint::new(enter.clone()) + .elevation(ElevationIndex::EditorSurface) + .prefix("Editor:") + .suffix("Surface") + .into_any_element(), + ), + single_example( + "Modal Surface", + KeybindingHint::new(enter.clone()) + .elevation(ElevationIndex::ModalSurface) + .prefix("Modal:") + .suffix("Enter") + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index d528f47218a46689611329360574d61613f9d31f..14ea7a5cf165a8867d95e15f6c136577980a3a58 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -75,6 +75,11 @@ impl LabelCommon for HighlightedLabel { self.base = self.base.single_line(); self } + + fn buffer_font(mut self, cx: &App) -> Self { + self.base = self.base.buffer_font(cx); + self + } } pub fn highlight_ranges( diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 5f170b9a1520363893bef78b520242bc83c0f6eb..59243998df40dd5fc857f6f3d9c26dffc4972476 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -1,6 +1,6 @@ #![allow(missing_docs)] -use gpui::{App, StyleRefinement, Window}; +use gpui::{AnyElement, App, StyleRefinement, Window}; use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle}; @@ -32,7 +32,7 @@ use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle}; /// /// let my_label = Label::new("Deleted").strikethrough(true); /// ``` -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Label { base: LabelLike, label: SharedString, @@ -172,6 +172,11 @@ impl LabelCommon for Label { self.base = self.base.single_line(); self } + + fn buffer_font(mut self, cx: &App) -> Self { + self.base = self.base.buffer_font(cx); + self + } } impl RenderOnce for Label { @@ -179,3 +184,53 @@ impl RenderOnce for Label { self.base.child(self.label) } } + +impl ComponentPreview for Label { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Sizes", + vec![ + single_example("Default", Label::new("Default Label").into_any_element()), + single_example("Small", Label::new("Small Label").size(LabelSize::Small).into_any_element()), + single_example("Large", Label::new("Large Label").size(LabelSize::Large).into_any_element()), + ], + ), + example_group_with_title( + "Colors", + vec![ + single_example("Default", Label::new("Default Color").into_any_element()), + single_example("Accent", Label::new("Accent Color").color(Color::Accent).into_any_element()), + single_example("Error", Label::new("Error Color").color(Color::Error).into_any_element()), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example("Default", Label::new("Default Style").into_any_element()), + single_example("Bold", Label::new("Bold Style").weight(gpui::FontWeight::BOLD).into_any_element()), + single_example("Italic", Label::new("Italic Style").italic(true).into_any_element()), + single_example("Strikethrough", Label::new("Strikethrough Style").strikethrough(true).into_any_element()), + single_example("Underline", Label::new("Underline Style").underline(true).into_any_element()), + ], + ), + example_group_with_title( + "Line Height Styles", + vec![ + single_example("Default", Label::new("Default Line Height").into_any_element()), + single_example("UI Label", Label::new("UI Label Line Height").line_height_style(LineHeightStyle::UiLabel).into_any_element()), + ], + ), + example_group_with_title( + "Special Cases", + vec![ + single_example("Single Line", Label::new("Single\nLine\nText").single_line().into_any_element()), + single_example("Text Ellipsis", Label::new("This is a very long text that should be truncated with an ellipsis").text_ellipsis().into_any_element()), + ], + ), + ]) + .into_any_element() + } +} diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index c9674f10a0173beb8823ceacc20bc99cfadab8d9..fad24d8699c0cbc81034cce48389d10d5e71b223 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -55,6 +55,9 @@ pub trait LabelCommon { /// Sets the label to render as a single line. fn single_line(self) -> Self; + + /// Sets the font to the buffer's + fn buffer_font(self, cx: &App) -> Self; } #[derive(IntoElement)] @@ -159,6 +162,13 @@ impl LabelCommon for LabelLike { self.single_line = true; self } + + fn buffer_font(mut self, cx: &App) -> Self { + self.base = self + .base + .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()); + self + } } impl ParentElement for LabelLike { diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index af801ec97c6e0bbb0522c7ebfa45536b3dd7a562..095b05793a773afb792170fe9eb7ce4f9202b5ff 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -3,8 +3,8 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - anchored, deferred, div, point, prelude::FluentBuilder, px, size, AnyElement, App, Bounds, - Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, Focusable as _, + anchored, deferred, div, point, prelude::FluentBuilder, px, size, AnyElement, AnyView, App, + Bounds, Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, Focusable as _, GlobalElementId, HitboxId, InteractiveElement, IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, Style, Window, }; @@ -178,6 +178,28 @@ impl PopoverMenu { self } + pub fn trigger_with_tooltip( + mut self, + t: T, + tooltip_builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + let on_open = self.on_open.clone(); + self.child_builder = Some(Box::new(move |menu, builder| { + let open = menu.borrow().is_some(); + t.toggle_state(open) + .when_some(builder, |el, builder| { + el.on_click(move |_, window, cx| { + show_menu(&builder, &menu, on_open.clone(), window, cx) + }) + .when(!open, |t| { + t.tooltip(move |window, cx| tooltip_builder(window, cx)) + }) + }) + .into_any_element() + })); + self + } + /// anchor defines which corner of the menu to anchor to the attachment point /// (by default the cursor position, but see attach) pub fn anchor(mut self, anchor: Corner) -> Self { diff --git a/crates/ui/src/components/radio.rs b/crates/ui/src/components/radio.rs index 6e98a10e0b08741594016da37ac3bdca1cb9cc9c..d7ee106d2d5bb0eae255ec59947836915065eb5b 100644 --- a/crates/ui/src/components/radio.rs +++ b/crates/ui/src/components/radio.rs @@ -4,9 +4,6 @@ use std::sync::Arc; use crate::prelude::*; -/// A [`Checkbox`] that has a [`Label`]. -/// -/// [`Checkbox`]: crate::components::Checkbox #[derive(IntoElement)] pub struct RadioWithLabel { id: ElementId, diff --git a/crates/ui/src/components/stories.rs b/crates/ui/src/components/stories.rs index b55aa064f92c3e2749fdcdfa53c476d92392825b..9161b14b47df7d278eb0fe2e41af2cf07bb1f269 100644 --- a/crates/ui/src/components/stories.rs +++ b/crates/ui/src/components/stories.rs @@ -15,7 +15,6 @@ mod list_item; mod tab; mod tab_bar; mod toggle_button; -mod tool_strip; pub use avatar::*; pub use button::*; @@ -31,4 +30,3 @@ pub use list_item::*; pub use tab::*; pub use tab_bar::*; pub use toggle_button::*; -pub use tool_strip::*; diff --git a/crates/ui/src/components/stories/tool_strip.rs b/crates/ui/src/components/stories/tool_strip.rs deleted file mode 100644 index 0a6a6b7ad0343be3fb8616129bfd212fc8f3acf9..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/stories/tool_strip.rs +++ /dev/null @@ -1,33 +0,0 @@ -use gpui::Render; -use story::{Story, StoryItem, StorySection}; - -use crate::{prelude::*, ToolStrip, Tooltip}; - -pub struct ToolStripStory; - -impl Render for ToolStripStory { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - Story::container() - .child(Story::title_for::()) - .child( - StorySection::new().child(StoryItem::new( - "Vertical Tool Strip", - h_flex().child( - ToolStrip::vertical("tool_strip_example") - .tool( - IconButton::new("example_tool", IconName::AudioOn) - .tooltip(Tooltip::text("Example tool")), - ) - .tool( - IconButton::new("example_tool_2", IconName::MicMute) - .tooltip(Tooltip::text("Example tool 2")), - ) - .tool( - IconButton::new("example_tool_3", IconName::Screen) - .tooltip(Tooltip::text("Example tool 3")), - ), - ), - )), - ) - } -} diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index 4d991bd6ce8482a1c123ebf3df58ab48f26b646f..362f1a41a59610678bd0f96603fadec34d47cb3c 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -27,7 +27,7 @@ pub enum TabCloseSide { End, } -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Tab { div: Stateful
, selected: bool, @@ -171,3 +171,48 @@ impl RenderOnce for Tab { ) } } + +impl ComponentPreview for Tab { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Variations", + vec![ + single_example( + "Default", + Tab::new("default").child("Default Tab").into_any_element(), + ), + single_example( + "Selected", + Tab::new("selected") + .toggle_state(true) + .child("Selected Tab") + .into_any_element(), + ), + single_example( + "First", + Tab::new("first") + .position(TabPosition::First) + .child("First Tab") + .into_any_element(), + ), + single_example( + "Middle", + Tab::new("middle") + .position(TabPosition::Middle(Ordering::Equal)) + .child("Middle Tab") + .into_any_element(), + ), + single_example( + "Last", + Tab::new("last") + .position(TabPosition::Last) + .child("Last Tab") + .into_any_element(), + ), + ], + )]) + .into_any_element() + } +} diff --git a/crates/ui/src/components/table.rs b/crates/ui/src/components/table.rs index c1918829791a6e14b7e9f8dbaeae9307c5439c23..aa955a6d089ee30d922b5cbc2104391e164a51ba 100644 --- a/crates/ui/src/components/table.rs +++ b/crates/ui/src/components/table.rs @@ -2,7 +2,7 @@ use crate::{prelude::*, Indicator}; use gpui::{div, AnyElement, FontWeight, IntoElement, Length}; /// A table component -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Table { column_headers: Vec, rows: Vec>, @@ -152,88 +152,110 @@ where } impl ComponentPreview for Table { - fn description() -> impl Into> { - "Used for showing tabular data. Tables may show both text and elements in their cells." - } - - fn example_label_side() -> ExampleLabelSide { - ExampleLabelSide::Top - } - - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - vec![ - example_group(vec![ - single_example( - "Simple Table", - Table::new(vec!["Name", "Age", "City"]) - .width(px(400.)) - .row(vec!["Alice", "28", "New York"]) - .row(vec!["Bob", "32", "San Francisco"]) - .row(vec!["Charlie", "25", "London"]), + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Tables", + vec![ + single_example( + "Simple Table", + Table::new(vec!["Name", "Age", "City"]) + .width(px(400.)) + .row(vec!["Alice", "28", "New York"]) + .row(vec!["Bob", "32", "San Francisco"]) + .row(vec!["Charlie", "25", "London"]) + .into_any_element(), + ), + single_example( + "Two Column Table", + Table::new(vec!["Category", "Value"]) + .width(px(300.)) + .row(vec!["Revenue", "$100,000"]) + .row(vec!["Expenses", "$75,000"]) + .row(vec!["Profit", "$25,000"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styled Tables", + vec![ + single_example( + "Default", + Table::new(vec!["Product", "Price", "Stock"]) + .width(px(400.)) + .row(vec!["Laptop", "$999", "In Stock"]) + .row(vec!["Phone", "$599", "Low Stock"]) + .row(vec!["Tablet", "$399", "Out of Stock"]) + .into_any_element(), + ), + single_example( + "Striped", + Table::new(vec!["Product", "Price", "Stock"]) + .width(px(400.)) + .striped() + .row(vec!["Laptop", "$999", "In Stock"]) + .row(vec!["Phone", "$599", "Low Stock"]) + .row(vec!["Tablet", "$399", "Out of Stock"]) + .row(vec!["Headphones", "$199", "In Stock"]) + .into_any_element(), + ), + ], ), - single_example( - "Two Column Table", - Table::new(vec!["Category", "Value"]) - .width(px(300.)) - .row(vec!["Revenue", "$100,000"]) - .row(vec!["Expenses", "$75,000"]) - .row(vec!["Profit", "$25,000"]), + example_group_with_title( + "Mixed Content Table", + vec![single_example( + "Table with Elements", + Table::new(vec!["Status", "Name", "Priority", "Deadline", "Action"]) + .width(px(840.)) + .row(vec![ + element_cell( + Indicator::dot().color(Color::Success).into_any_element(), + ), + string_cell("Project A"), + string_cell("High"), + string_cell("2023-12-31"), + element_cell( + Button::new("view_a", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ), + ]) + .row(vec![ + element_cell( + Indicator::dot().color(Color::Warning).into_any_element(), + ), + string_cell("Project B"), + string_cell("Medium"), + string_cell("2024-03-15"), + element_cell( + Button::new("view_b", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ), + ]) + .row(vec![ + element_cell( + Indicator::dot().color(Color::Error).into_any_element(), + ), + string_cell("Project C"), + string_cell("Low"), + string_cell("2024-06-30"), + element_cell( + Button::new("view_c", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ), + ]) + .into_any_element(), + )], ), - ]), - example_group(vec![single_example( - "Striped Table", - Table::new(vec!["Product", "Price", "Stock"]) - .width(px(600.)) - .striped() - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .row(vec!["Headphones", "$199", "In Stock"]), - )]), - example_group_with_title( - "Mixed Content Table", - vec![single_example( - "Table with Elements", - Table::new(vec!["Status", "Name", "Priority", "Deadline", "Action"]) - .width(px(840.)) - .row(vec![ - element_cell(Indicator::dot().color(Color::Success).into_any_element()), - string_cell("Project A"), - string_cell("High"), - string_cell("2023-12-31"), - element_cell( - Button::new("view_a", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ), - ]) - .row(vec![ - element_cell(Indicator::dot().color(Color::Warning).into_any_element()), - string_cell("Project B"), - string_cell("Medium"), - string_cell("2024-03-15"), - element_cell( - Button::new("view_b", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ), - ]) - .row(vec![ - element_cell(Indicator::dot().color(Color::Error).into_any_element()), - string_cell("Project C"), - string_cell("Low"), - string_cell("2024-06-30"), - element_cell( - Button::new("view_c", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ), - ]), - )], - ), - ] + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 0413891811d4baf07d243233db50d2a42b8d3cff..1f095065c30386375b7e1bfeb0d1adce5d2f3c8e 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -1,5 +1,6 @@ use gpui::{ - div, hsla, prelude::*, AnyView, CursorStyle, ElementId, Hsla, IntoElement, Styled, Window, + div, hsla, prelude::*, AnyElement, AnyView, CursorStyle, ElementId, Hsla, IntoElement, Styled, + Window, }; use std::sync::Arc; @@ -38,7 +39,8 @@ pub enum ToggleStyle { /// Checkboxes are used for multiple choices, not for mutually exclusive choices. /// Each checkbox works independently from other checkboxes in the list, /// therefore checking an additional box does not affect any other selections. -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] +#[component(scope = "input")] pub struct Checkbox { id: ElementId, toggle_state: ToggleState, @@ -237,7 +239,8 @@ impl RenderOnce for Checkbox { } /// A [`Checkbox`] that has a [`Label`]. -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] +#[component(scope = "input")] pub struct CheckboxWithLabel { id: ElementId, label: Label, @@ -314,7 +317,8 @@ impl RenderOnce for CheckboxWithLabel { /// # Switch /// /// Switches are used to represent opposite states, such as enabled or disabled. -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] +#[component(scope = "input")] pub struct Switch { id: ElementId, toggle_state: ToggleState, @@ -429,6 +433,7 @@ impl RenderOnce for Switch { h_flex() .id(self.id) .gap(DynamicSpacing::Base06.rems(cx)) + .cursor_pointer() .child(switch) .when_some( self.on_click.filter(|_| !self.disabled), @@ -445,286 +450,249 @@ impl RenderOnce for Switch { } } +/// A [`Switch`] that has a [`Label`]. +#[derive(IntoElement)] +// #[component(scope = "input")] +pub struct SwitchWithLabel { + id: ElementId, + label: Label, + toggle_state: ToggleState, + on_click: Arc, + disabled: bool, +} + +impl SwitchWithLabel { + /// Creates a switch with an attached label. + pub fn new( + id: impl Into, + label: Label, + toggle_state: impl Into, + on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + label, + toggle_state: toggle_state.into(), + on_click: Arc::new(on_click), + disabled: false, + } + } + + /// Sets the disabled state of the [`SwitchWithLabel`]. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl RenderOnce for SwitchWithLabel { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(SharedString::from(format!("{}-container", self.id))) + .gap(DynamicSpacing::Base08.rems(cx)) + .child( + Switch::new(self.id.clone(), self.toggle_state) + .disabled(self.disabled) + .on_click({ + let on_click = self.on_click.clone(); + move |checked, window, cx| { + (on_click)(checked, window, cx); + } + }), + ) + .child( + div() + .id(SharedString::from(format!("{}-label", self.id))) + .child(self.label), + ) + } +} + impl ComponentPreview for Checkbox { - fn description() -> impl Into> { - "A checkbox lets people choose between a pair of opposing states, like enabled and disabled, using a different appearance to indicate each state." + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_unselected", ToggleState::Unselected) + .into_any_element(), + ), + single_example( + "Indeterminate", + Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate) + .into_any_element(), + ), + single_example( + "Selected", + Checkbox::new("checkbox_selected", ToggleState::Selected) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Default", + Checkbox::new("checkbox_default", ToggleState::Selected) + .into_any_element(), + ), + single_example( + "Filled", + Checkbox::new("checkbox_filled", ToggleState::Selected) + .fill() + .into_any_element(), + ), + single_example( + "ElevationBased", + Checkbox::new("checkbox_elevation", ToggleState::Selected) + .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)) + .into_any_element(), + ), + single_example( + "Custom Color", + Checkbox::new("checkbox_custom", ToggleState::Selected) + .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_disabled_unselected", ToggleState::Unselected) + .disabled(true) + .into_any_element(), + ), + single_example( + "Selected", + Checkbox::new("checkbox_disabled_selected", ToggleState::Selected) + .disabled(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Label", + vec![single_example( + "Default", + Checkbox::new("checkbox_with_label", ToggleState::Selected) + .label("Always save on quit") + .into_any_element(), + )], + ), + ]) + .into_any_element() } +} - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - vec![ - example_group_with_title( - "Default", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_unselected", ToggleState::Unselected), - ), - single_example( - "Indeterminate", - Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate), - ), - single_example( - "Selected", - Checkbox::new("checkbox_selected", ToggleState::Selected), - ), - ], - ), - example_group_with_title( - "Default (Filled)", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_unselected", ToggleState::Unselected).fill(), - ), - single_example( - "Indeterminate", - Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate).fill(), - ), - single_example( - "Selected", - Checkbox::new("checkbox_selected", ToggleState::Selected).fill(), - ), - ], - ), - example_group_with_title( - "ElevationBased", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_unfilled_unselected", ToggleState::Unselected) - .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)), - ), - single_example( - "Indeterminate", - Checkbox::new( - "checkbox_unfilled_indeterminate", - ToggleState::Indeterminate, - ) - .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)), - ), - single_example( - "Selected", - Checkbox::new("checkbox_unfilled_selected", ToggleState::Selected) - .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)), - ), - ], - ), - example_group_with_title( - "ElevationBased (Filled)", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_filled_unselected", ToggleState::Unselected) - .fill() - .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)), - ), - single_example( - "Indeterminate", - Checkbox::new("checkbox_filled_indeterminate", ToggleState::Indeterminate) - .fill() - .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)), - ), - single_example( - "Selected", - Checkbox::new("checkbox_filled_selected", ToggleState::Selected) - .fill() - .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)), - ), - ], - ), - example_group_with_title( - "Custom Color", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_custom_unselected", ToggleState::Unselected) - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))), - ), - single_example( - "Indeterminate", - Checkbox::new("checkbox_custom_indeterminate", ToggleState::Indeterminate) - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))), - ), - single_example( - "Selected", - Checkbox::new("checkbox_custom_selected", ToggleState::Selected) - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))), - ), - ], - ), - example_group_with_title( - "Custom Color (Filled)", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_custom_filled_unselected", ToggleState::Unselected) - .fill() - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))), - ), - single_example( - "Indeterminate", - Checkbox::new( - "checkbox_custom_filled_indeterminate", - ToggleState::Indeterminate, - ) - .fill() - .style(ToggleStyle::Custom(hsla( - 142.0 / 360., - 0.68, - 0.45, - 0.7, - ))), - ), - single_example( - "Selected", - Checkbox::new("checkbox_custom_filled_selected", ToggleState::Selected) - .fill() - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_disabled_unselected", ToggleState::Unselected) - .disabled(true), - ), - single_example( - "Indeterminate", - Checkbox::new( - "checkbox_disabled_indeterminate", - ToggleState::Indeterminate, - ) - .disabled(true), - ), - single_example( - "Selected", - Checkbox::new("checkbox_disabled_selected", ToggleState::Selected) - .disabled(true), - ), - ], - ), - example_group_with_title( - "Disabled (Filled)", +impl ComponentPreview for Switch { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Off", + Switch::new("switch_off", ToggleState::Unselected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + single_example( + "On", + Switch::new("switch_on", ToggleState::Selected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![ + single_example( + "Off", + Switch::new("switch_disabled_off", ToggleState::Unselected) + .disabled(true) + .into_any_element(), + ), + single_example( + "On", + Switch::new("switch_disabled_on", ToggleState::Selected) + .disabled(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Label", + vec![ + single_example( + "Label", + Switch::new("switch_with_label", ToggleState::Selected) + .label("Always save on quit") + .into_any_element(), + ), + // TODO: Where did theme_preview_keybinding go? + // single_example( + // "Keybinding", + // Switch::new("switch_with_keybinding", ToggleState::Selected) + // .key_binding(theme_preview_keybinding("cmd-shift-e")) + // .into_any_element(), + // ), + ], + ), + ]) + .into_any_element() + } +} + +impl ComponentPreview for CheckboxWithLabel { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "States", vec![ single_example( "Unselected", - Checkbox::new( - "checkbox_disabled_filled_unselected", + CheckboxWithLabel::new( + "checkbox_with_label_unselected", + Label::new("Always save on quit"), ToggleState::Unselected, + |_, _, _| {}, ) - .fill() - .disabled(true), + .into_any_element(), ), single_example( "Indeterminate", - Checkbox::new( - "checkbox_disabled_filled_indeterminate", + CheckboxWithLabel::new( + "checkbox_with_label_indeterminate", + Label::new("Always save on quit"), ToggleState::Indeterminate, + |_, _, _| {}, ) - .fill() - .disabled(true), + .into_any_element(), ), single_example( "Selected", - Checkbox::new("checkbox_disabled_filled_selected", ToggleState::Selected) - .fill() - .disabled(true), - ), - ], - ), - ] - } -} - -impl ComponentPreview for Switch { - fn description() -> impl Into> { - "A switch toggles between two mutually exclusive states, typically used for enabling or disabling a setting." - } - - fn examples(_window: &mut Window, _cx: &mut App) -> Vec> { - vec![ - example_group_with_title( - "Default", - vec![ - single_example( - "Off", - Switch::new("switch_off", ToggleState::Unselected).on_click(|_, _, _cx| {}), - ), - single_example( - "On", - Switch::new("switch_on", ToggleState::Selected).on_click(|_, _, _cx| {}), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![ - single_example( - "Off", - Switch::new("switch_disabled_off", ToggleState::Unselected).disabled(true), - ), - single_example( - "On", - Switch::new("switch_disabled_on", ToggleState::Selected).disabled(true), - ), - ], - ), - example_group_with_title( - "Label Permutations", - vec![ - single_example( - "Label", - Switch::new("switch_with_label", ToggleState::Selected) - .label("Always save on quit"), - ), - single_example( - "Keybinding", - Switch::new("switch_with_label", ToggleState::Selected) - .key_binding(theme_preview_keybinding("cmd-shift-e")), + CheckboxWithLabel::new( + "checkbox_with_label_selected", + Label::new("Always save on quit"), + ToggleState::Selected, + |_, _, _| {}, + ) + .into_any_element(), ), ], - ), - ] - } -} - -impl ComponentPreview for CheckboxWithLabel { - fn description() -> impl Into> { - "A checkbox with an associated label, allowing users to select an option while providing a descriptive text." - } - - fn examples(_window: &mut Window, _: &mut App) -> Vec> { - vec![example_group(vec![ - single_example( - "Unselected", - CheckboxWithLabel::new( - "checkbox_with_label_unselected", - Label::new("Always save on quit"), - ToggleState::Unselected, - |_, _, _| {}, - ), - ), - single_example( - "Indeterminate", - CheckboxWithLabel::new( - "checkbox_with_label_indeterminate", - Label::new("Always save on quit"), - ToggleState::Indeterminate, - |_, _, _| {}, - ), - ), - single_example( - "Selected", - CheckboxWithLabel::new( - "checkbox_with_label_selected", - Label::new("Always save on quit"), - ToggleState::Selected, - |_, _, _| {}, - ), - ), - ])] + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tool_strip.rs b/crates/ui/src/components/tool_strip.rs deleted file mode 100644 index 00166c8d7ef8c3b71e40179bcecdc9867dd73efd..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/tool_strip.rs +++ /dev/null @@ -1,58 +0,0 @@ -#![allow(missing_docs)] - -use gpui::Axis; - -use crate::prelude::*; - -#[derive(IntoElement)] -pub struct ToolStrip { - id: ElementId, - tools: Vec, - axis: Axis, -} - -impl ToolStrip { - fn new(id: ElementId, axis: Axis) -> Self { - Self { - id, - tools: vec![], - axis, - } - } - - pub fn vertical(id: impl Into) -> Self { - Self::new(id.into(), Axis::Vertical) - } - - pub fn tools(mut self, tools: Vec) -> Self { - self.tools = tools; - self - } - - pub fn tool(mut self, tool: IconButton) -> Self { - self.tools.push(tool); - self - } -} - -impl RenderOnce for ToolStrip { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let group = format!("tool_strip_{}", self.id.clone()); - - div() - .id(self.id.clone()) - .group(group) - .map(|element| match self.axis { - Axis::Vertical => element.v_flex(), - Axis::Horizontal => element.h_flex(), - }) - .flex_none() - .gap(DynamicSpacing::Base04.rems(cx)) - .p(DynamicSpacing::Base02.rems(cx)) - .border_1() - .border_color(cx.theme().colors().border) - .rounded(rems_from_px(6.0)) - .bg(cx.theme().colors().elevated_surface_background) - .children(self.tools) - } -} diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 753937810f2e603f8f64b5ec9ef5676a918c6e81..c2a3ae69eb0d8e0ba9dce4b29827e5030d4d321e 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -1,12 +1,13 @@ #![allow(missing_docs)] -use gpui::{Action, AnyView, AppContext as _, FocusHandle, IntoElement, Render}; +use gpui::{Action, AnyElement, AnyView, AppContext as _, FocusHandle, IntoElement, Render}; use settings::Settings; use theme::ThemeSettings; use crate::prelude::*; use crate::{h_flex, v_flex, Color, KeyBinding, Label, LabelSize, StyledExt}; +#[derive(IntoComponent)] pub struct Tooltip { title: SharedString, meta: Option, @@ -35,6 +36,22 @@ impl Tooltip { } } + pub fn for_action_title( + title: impl Into, + action: &dyn Action, + ) -> impl Fn(&mut Window, &mut App) -> AnyView { + let title = title.into(); + let action = action.boxed_clone(); + move |window, cx| { + cx.new(|_| Self { + title: title.clone(), + meta: None, + key_binding: KeyBinding::for_action(action.as_ref(), window), + }) + .into() + } + } + pub fn for_action( title: impl Into, action: &dyn Action, @@ -188,3 +205,15 @@ impl Render for LinkPreview { }) } } + +impl ComponentPreview for Tooltip { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + example_group(vec![single_example( + "Text only", + Button::new("delete-example", "Delete") + .tooltip(Tooltip::text("This is a tooltip!")) + .into_any_element(), + )]) + .into_any_element() + } +} diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index 6bb9d2cb400122a129cc54b694f56f4dcdf2e415..ba02dd5aeaccf264c739fb3ac517a75294a7f519 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -6,9 +6,11 @@ pub use gpui::{ InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, Window, }; +pub use component::{example_group, example_group_with_title, single_example, ComponentPreview}; +pub use ui_macros::IntoComponent; + pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle, StyledTypography, TextSize}; pub use crate::traits::clickable::*; -pub use crate::traits::component_preview::*; pub use crate::traits::disableable::*; pub use crate::traits::fixed::*; pub use crate::traits::styled_ext::*; diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index a8cf1d51e50adf8d9733fb77ace01791300e6903..0d234ad50d9bcd6f1a39f1f052d8a361479664f7 100644 --- a/crates/ui/src/styles/color.rs +++ b/crates/ui/src/styles/color.rs @@ -86,3 +86,9 @@ impl Color { } } } + +impl From for Color { + fn from(color: Hsla) -> Self { + Color::Custom(color) + } +} diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 1f6c2e91127db79809cb3aedaadbfa234841c7e3..ec9c92cef9d2c467bc6e2c01caba28e3011e415e 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -1,5 +1,7 @@ +use crate::prelude::*; use gpui::{ - div, rems, App, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, Window, + div, rems, AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, + Window, }; use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; @@ -188,7 +190,7 @@ impl HeadlineSize { /// A headline element, used to emphasize some text and /// create a visual hierarchy. -#[derive(IntoElement)] +#[derive(IntoElement, IntoComponent)] pub struct Headline { size: HeadlineSize, text: SharedString, @@ -230,3 +232,44 @@ impl Headline { self } } + +impl ComponentPreview for Headline { + fn preview(_window: &mut Window, _cx: &App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Headline Sizes", + vec![ + single_example( + "XLarge", + Headline::new("XLarge Headline") + .size(HeadlineSize::XLarge) + .into_any_element(), + ), + single_example( + "Large", + Headline::new("Large Headline") + .size(HeadlineSize::Large) + .into_any_element(), + ), + single_example( + "Medium (Default)", + Headline::new("Medium Headline").into_any_element(), + ), + single_example( + "Small", + Headline::new("Small Headline") + .size(HeadlineSize::Small) + .into_any_element(), + ), + single_example( + "XSmall", + Headline::new("XSmall Headline") + .size(HeadlineSize::XSmall) + .into_any_element(), + ), + ], + )]) + .into_any_element() + } +} diff --git a/crates/ui/src/traits.rs b/crates/ui/src/traits.rs index 1b4d76171100c3b72bb76564c8af34827995c3ac..628c76aaddecaa291b3cfad2e6d16ccd6478c767 100644 --- a/crates/ui/src/traits.rs +++ b/crates/ui/src/traits.rs @@ -1,5 +1,4 @@ pub mod clickable; -pub mod component_preview; pub mod disableable; pub mod fixed; pub mod styled_ext; diff --git a/crates/ui/src/traits/component_preview.rs b/crates/ui/src/traits/component_preview.rs deleted file mode 100644 index 42c6cf9e4cf5561a7a31e6cfc6bc7a80d98630a6..0000000000000000000000000000000000000000 --- a/crates/ui/src/traits/component_preview.rs +++ /dev/null @@ -1,205 +0,0 @@ -#![allow(missing_docs)] -use crate::{prelude::*, KeyBinding}; -use gpui::{AnyElement, SharedString}; - -/// Which side of the preview to show labels on -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExampleLabelSide { - /// Left side - Left, - /// Right side - Right, - #[default] - /// Top side - Top, - /// Bottom side - Bottom, -} - -/// Implement this trait to enable rich UI previews with metadata in the Theme Preview tool. -pub trait ComponentPreview: IntoElement { - fn title() -> &'static str { - std::any::type_name::() - } - - fn description() -> impl Into> { - None - } - - fn example_label_side() -> ExampleLabelSide { - ExampleLabelSide::default() - } - - fn examples(_window: &mut Window, _cx: &mut App) -> Vec>; - - fn custom_example(_window: &mut Window, _cx: &mut App) -> impl Into> { - None:: - } - - fn component_previews(window: &mut Window, cx: &mut App) -> Vec { - Self::examples(window, cx) - .into_iter() - .map(|example| Self::render_example_group(example)) - .collect() - } - - fn render_component_previews(window: &mut Window, cx: &mut App) -> AnyElement { - let title = Self::title(); - let (source, title) = title - .rsplit_once("::") - .map_or((None, title), |(s, t)| (Some(s), t)); - let description = Self::description().into(); - - v_flex() - .w_full() - .gap_6() - .p_4() - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child( - v_flex() - .gap_1() - .child( - h_flex() - .gap_1() - .child(Headline::new(title).size(HeadlineSize::Small)) - .when_some(source, |this, source| { - this.child(Label::new(format!("({})", source)).color(Color::Muted)) - }), - ) - .when_some(description, |this, description| { - this.child( - div() - .text_ui_sm(cx) - .text_color(cx.theme().colors().text_muted) - .max_w(px(600.0)) - .child(description), - ) - }), - ) - .when_some( - Self::custom_example(window, cx).into(), - |this, custom_example| this.child(custom_example), - ) - .children(Self::component_previews(window, cx)) - .into_any_element() - } - - fn render_example_group(group: ComponentExampleGroup) -> AnyElement { - v_flex() - .gap_6() - .when(group.grow, |this| this.w_full().flex_1()) - .when_some(group.title, |this, title| { - this.child(Label::new(title).size(LabelSize::Small)) - }) - .child( - h_flex() - .w_full() - .gap_6() - .children(group.examples.into_iter().map(Self::render_example)) - .into_any_element(), - ) - .into_any_element() - } - - fn render_example(example: ComponentExample) -> AnyElement { - let base = div().flex(); - - let base = match Self::example_label_side() { - ExampleLabelSide::Right => base.flex_row(), - ExampleLabelSide::Left => base.flex_row_reverse(), - ExampleLabelSide::Bottom => base.flex_col(), - ExampleLabelSide::Top => base.flex_col_reverse(), - }; - - base.gap_1() - .when(example.grow, |this| this.flex_1()) - .child(example.element) - .child( - Label::new(example.variant_name) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element() - } -} - -/// A single example of a component. -pub struct ComponentExample { - variant_name: SharedString, - element: T, - grow: bool, -} - -impl ComponentExample { - /// Create a new example with the given variant name and example value. - pub fn new(variant_name: impl Into, example: T) -> Self { - Self { - variant_name: variant_name.into(), - element: example, - grow: false, - } - } - - /// Set the example to grow to fill the available horizontal space. - pub fn grow(mut self) -> Self { - self.grow = true; - self - } -} - -/// A group of component examples. -pub struct ComponentExampleGroup { - pub title: Option, - pub examples: Vec>, - pub grow: bool, -} - -impl ComponentExampleGroup { - /// Create a new group of examples with the given title. - pub fn new(examples: Vec>) -> Self { - Self { - title: None, - examples, - grow: false, - } - } - - /// Create a new group of examples with the given title. - pub fn with_title(title: impl Into, examples: Vec>) -> Self { - Self { - title: Some(title.into()), - examples, - grow: false, - } - } - - /// Set the group to grow to fill the available horizontal space. - pub fn grow(mut self) -> Self { - self.grow = true; - self - } -} - -/// Create a single example -pub fn single_example(variant_name: impl Into, example: T) -> ComponentExample { - ComponentExample::new(variant_name, example) -} - -/// Create a group of examples without a title -pub fn example_group(examples: Vec>) -> ComponentExampleGroup { - ComponentExampleGroup::new(examples) -} - -/// Create a group of examples with a title -pub fn example_group_with_title( - title: impl Into, - examples: Vec>, -) -> ComponentExampleGroup { - ComponentExampleGroup::with_title(title, examples) -} - -pub fn theme_preview_keybinding(keystrokes: &str) -> KeyBinding { - KeyBinding::new(gpui::KeyBinding::new(keystrokes, gpui::NoAction {}, None)) -} diff --git a/crates/ui_macros/Cargo.toml b/crates/ui_macros/Cargo.toml index 773c07d2383b62d62f948d986c275635fbfa2e08..cf9fef994f87ee89b9e70e6c6c3eee674f020846 100644 --- a/crates/ui_macros/Cargo.toml +++ b/crates/ui_macros/Cargo.toml @@ -13,7 +13,8 @@ path = "src/ui_macros.rs" proc-macro = true [dependencies] +convert_case.workspace = true +linkme.workspace = true proc-macro2.workspace = true quote.workspace = true syn.workspace = true -convert_case.workspace = true diff --git a/crates/ui_macros/src/derive_component.rs b/crates/ui_macros/src/derive_component.rs new file mode 100644 index 0000000000000000000000000000000000000000..5103d219c2ba5c7193c6ee8885ea8f71db772fb1 --- /dev/null +++ b/crates/ui_macros/src/derive_component.rs @@ -0,0 +1,97 @@ +use convert_case::{Case, Casing}; +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, DeriveInput, Lit, Meta, MetaList, MetaNameValue, NestedMeta}; + +pub fn derive_into_component(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let mut scope_val = None; + let mut description_val = None; + + for attr in &input.attrs { + if attr.path.is_ident("component") { + if let Ok(Meta::List(MetaList { nested, .. })) = attr.parse_meta() { + for item in nested { + if let NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit: Lit::Str(s), + .. + })) = item + { + let ident = path.get_ident().map(|i| i.to_string()).unwrap_or_default(); + if ident == "scope" { + scope_val = Some(s.value()); + } else if ident == "description" { + description_val = Some(s.value()); + } + } + } + } + } + } + + let name = &input.ident; + + let scope_impl = if let Some(s) = scope_val { + quote! { + fn scope() -> Option<&'static str> { + Some(#s) + } + } + } else { + quote! { + fn scope() -> Option<&'static str> { + None + } + } + }; + + let description_impl = if let Some(desc) = description_val { + quote! { + fn description() -> Option<&'static str> { + Some(#desc) + } + } + } else { + quote! {} + }; + + let register_component_name = syn::Ident::new( + &format!( + "__register_component_{}", + Casing::to_case(&name.to_string(), Case::Snake) + ), + name.span(), + ); + let register_preview_name = syn::Ident::new( + &format!( + "__register_preview_{}", + Casing::to_case(&name.to_string(), Case::Snake) + ), + name.span(), + ); + + let expanded = quote! { + impl component::Component for #name { + #scope_impl + + fn name() -> &'static str { + stringify!(#name) + } + + #description_impl + } + + #[linkme::distributed_slice(component::__ALL_COMPONENTS)] + fn #register_component_name() { + component::register_component::<#name>(); + } + + #[linkme::distributed_slice(component::__ALL_PREVIEWS)] + fn #register_preview_name() { + component::register_preview::<#name>(); + } + }; + + expanded.into() +} diff --git a/crates/ui_macros/src/ui_macros.rs b/crates/ui_macros/src/ui_macros.rs index cd4b852766cb54f385fe4eda34c0b1c9ccc1c9b4..7898f226b037af6bb44b05b55dcb57e6f9584c9b 100644 --- a/crates/ui_macros/src/ui_macros.rs +++ b/crates/ui_macros/src/ui_macros.rs @@ -1,3 +1,4 @@ +mod derive_component; mod derive_path_str; mod dynamic_spacing; @@ -58,3 +59,27 @@ pub fn path_str(_args: TokenStream, input: TokenStream) -> TokenStream { pub fn derive_dynamic_spacing(input: TokenStream) -> TokenStream { dynamic_spacing::derive_spacing(input) } + +/// Derives the `Component` trait for a struct. +/// +/// This macro generates implementations for the `Component` trait and associated +/// registration functions for the component system. +/// +/// # Attributes +/// +/// - `#[component(scope = "...")]`: Required. Specifies the scope of the component. +/// - `#[component(description = "...")]`: Optional. Provides a description for the component. +/// +/// # Example +/// +/// ``` +/// use ui_macros::Component; +/// +/// #[derive(Component)] +/// #[component(scope = "toggle", description = "A element that can be toggled on and off")] +/// struct Checkbox; +/// ``` +#[proc_macro_derive(IntoComponent, attributes(component))] +pub fn derive_component(input: TokenStream) -> TokenStream { + derive_component::derive_into_component(input) +} diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index b3d0c28bbba40dddbc5049858d0a152093637b7c..c2d66b573e321c77195cecc0e71d07ec8138d1da 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -105,7 +105,7 @@ impl> PathExt for T { /// leverages Rust's type system to ensure that all paths entering Zed are always "sanitized" by removing the `\\\\?\\` prefix. /// On non-Windows operating systems, this struct is effectively a no-op. #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct SanitizedPath(Arc); +pub struct SanitizedPath(pub Arc); impl SanitizedPath { pub fn starts_with(&self, prefix: &SanitizedPath) -> bool { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 285f79095a904e86cc361bc561e20a22f0b37a5f..ed5e3c21bf2fa59bcced32222c6795ec89cad6a5 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -407,6 +407,9 @@ impl Object { if let Some(range) = self.range(map, selection.clone(), around) { selection.start = range.start; selection.end = range.end; + if !around && self.is_multiline() { + preserve_indented_newline(map, selection); + } true } else { false @@ -414,6 +417,49 @@ impl Object { } } +/// Returns a range without the final newline char. +/// +/// If the selection spans multiple lines and is preceded by an opening brace (`{`), +/// this function will trim the selection to exclude the final newline +/// in order to preserve a properly indented line. +fn preserve_indented_newline(map: &DisplaySnapshot, selection: &mut Selection) { + let (start_point, end_point) = (selection.start.to_point(map), selection.end.to_point(map)); + + if start_point.row == end_point.row { + return; + } + + let start_offset = selection.start.to_offset(map, Bias::Left); + let mut pos = start_offset; + + while pos > 0 { + pos -= 1; + let current_char = map.buffer_chars_at(pos).next().map(|(ch, _)| ch); + + match current_char { + Some(ch) if !ch.is_whitespace() => break, + Some('\n') if pos > 0 => { + let prev_char = map.buffer_chars_at(pos - 1).next().map(|(ch, _)| ch); + if prev_char == Some('{') { + let end_pos = selection.end.to_offset(map, Bias::Left); + for (ch, offset) in map.reverse_buffer_chars_at(end_pos) { + match ch { + '\n' => { + selection.end = offset.to_display_point(map); + break; + } + ch if !ch.is_whitespace() => break, + _ => continue, + } + } + } + break; + } + _ => continue, + } + } +} + /// Returns a range that surrounds the word `relative_to` is in. /// /// If `relative_to` is at the start of a word, return the word. @@ -1333,20 +1379,38 @@ fn surrounding_markers( } if !around && search_across_lines { + // Handle trailing newline after opening if let Some((ch, range)) = movement::chars_after(map, opening.end).next() { if ch == '\n' { - opening.end = range.end + opening.end = range.end; + + // After newline, skip leading whitespace + let mut chars = movement::chars_after(map, opening.end).peekable(); + while let Some((ch, range)) = chars.peek() { + if !ch.is_whitespace() { + break; + } + opening.end = range.end; + chars.next(); + } } } + // Handle leading whitespace before closing + let mut last_newline_end = None; for (ch, range) in movement::chars_before(map, closing.start) { if !ch.is_whitespace() { break; } - if ch != '\n' { - closing.start = range.start + if ch == '\n' { + last_newline_end = Some(range.end); + break; } } + // Adjust closing.start to exclude whitespace after a newline, if present + if let Some(end) = last_newline_end { + closing.start = end; + } } let result = if around { @@ -1681,60 +1745,46 @@ mod test { #[gpui::test] async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx).await; + let mut cx = VimTestContext::new(cx, true).await; - cx.set_shared_state(indoc! { - "func empty(a string) bool { - if a == \"\" { - return true - } - ˇreturn false - }" - }) - .await; - cx.simulate_shared_keystrokes("v i {").await; - cx.shared_state().await.assert_eq(indoc! {" - func empty(a string) bool { - « if a == \"\" { - return true - } - return false - ˇ»}"}); - cx.set_shared_state(indoc! { - "func empty(a string) bool { - if a == \"\" { - ˇreturn true - } - return false - }" - }) - .await; - cx.simulate_shared_keystrokes("v i {").await; - cx.shared_state().await.assert_eq(indoc! {" - func empty(a string) bool { - if a == \"\" { - « return true - ˇ» } - return false - }"}); + cx.set_state( + indoc! { + "func empty(a string) bool { + if a == \"\" { + return true + } + ˇreturn false + }" + }, + Mode::Normal, + ); + cx.simulate_keystrokes("v i {"); - cx.set_shared_state(indoc! { - "func empty(a string) bool { - if a == \"\" ˇ{ - return true - } - return false - }" - }) - .await; - cx.simulate_shared_keystrokes("v i {").await; - cx.shared_state().await.assert_eq(indoc! {" - func empty(a string) bool { - if a == \"\" { - « return true - ˇ» } - return false - }"}); + cx.set_state( + indoc! { + "func empty(a string) bool { + if a == \"\" { + ˇreturn true + } + return false + }" + }, + Mode::Normal, + ); + cx.simulate_keystrokes("v i {"); + + cx.set_state( + indoc! { + "func empty(a string) bool { + if a == \"\" ˇ{ + return true + } + return false + }" + }, + Mode::Normal, + ); + cx.simulate_keystrokes("v i {"); } #[gpui::test] @@ -2254,6 +2304,20 @@ mod test { } } + #[gpui::test] + async fn test_anybrackets_trailing_space(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("(trailingˇ whitespace )") + .await; + cx.simulate_shared_keystrokes("v i b").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("escape y i b").await; + cx.shared_clipboard() + .await + .assert_eq("trailing whitespace "); + } + #[gpui::test] async fn test_tags(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new_html(cx).await; diff --git a/crates/vim/test_data/test_anybrackets_trailing_space.json b/crates/vim/test_data/test_anybrackets_trailing_space.json new file mode 100644 index 0000000000000000000000000000000000000000..ed3f47df6c8c7c9f9249a52bcad9f008f79076d2 --- /dev/null +++ b/crates/vim/test_data/test_anybrackets_trailing_space.json @@ -0,0 +1,11 @@ +{"Put":{"state":"(trailingˇ whitespace )"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"b"} +{"Get":{"state":"(«trailing whitespace ˇ»)","mode":"Visual"}} +{"Key":"escape"} +{"Key":"y"} +{"Key":"i"} +{"Key":"b"} +{"Get":{"state":"(ˇtrailing whitespace )","mode":"Normal"}} +{"ReadRegister":{"name":"\"","value":"trailing whitespace "}} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 81bd40970e5871a393b3fbb8cca21f9ff22e3cdd..83ed9d5390a739d0775abe51d0926b4e369c8866 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -34,6 +34,7 @@ call.workspace = true client.workspace = true clock.workspace = true collections.workspace = true +component.workspace = true db.workspace = true derive_more.workspace = true fs.workspace = true diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index ad491910d06fc0e4fc5cf2cddd46ca2739b16c2e..797c9754edd28ea9a96f33b9663d1cfa5aca3760 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -448,6 +448,14 @@ pub mod simple_message_notification { self } + pub fn primary_on_click_arc(mut self, on_click: Arc) -> Self + where + F: 'static + Fn(&mut Window, &mut Context), + { + self.primary_on_click = Some(on_click); + self + } + pub fn secondary_message(mut self, message: S) -> Self where S: Into, @@ -474,6 +482,14 @@ pub mod simple_message_notification { self } + pub fn secondary_on_click_arc(mut self, on_click: Arc) -> Self + where + F: 'static + Fn(&mut Window, &mut Context), + { + self.secondary_on_click = Some(on_click); + self + } + pub fn more_info_message(mut self, message: S) -> Self where S: Into, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 7f8596112016f4a56ae99f477d26763311cddbb1..7e628accdd7f7314728f1b51fbce378f9af84e85 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -441,10 +441,9 @@ impl Pane { .gap(DynamicSpacing::Base04.rems(cx)) .child( PopoverMenu::new("pane-tab-bar-popover-menu") - .trigger( - IconButton::new("plus", IconName::Plus) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("New...")), + .trigger_with_tooltip( + IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small), + Tooltip::text("New..."), ) .anchor(Corner::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) @@ -474,10 +473,10 @@ impl Pane { ) .child( PopoverMenu::new("pane-tab-bar-split") - .trigger( + .trigger_with_tooltip( IconButton::new("split", IconName::Split) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Split Pane")), + .icon_size(IconSize::Small), + Tooltip::text("Split Pane"), ) .anchor(Corner::TopRight) .with_handle(pane.split_item_context_menu_handle.clone()) diff --git a/crates/workspace/src/theme_preview.rs b/crates/workspace/src/theme_preview.rs index 656fb9a4aca2c8957290a10982068c398811c19b..da2d6b3ff196342865b951f535acfadf25deb75d 100644 --- a/crates/workspace/src/theme_preview.rs +++ b/crates/workspace/src/theme_preview.rs @@ -27,7 +27,6 @@ pub fn init(cx: &mut App) { enum ThemePreviewPage { Overview, Typography, - Components, } impl ThemePreviewPage { @@ -35,7 +34,6 @@ impl ThemePreviewPage { match self { Self::Overview => "Overview", Self::Typography => "Typography", - Self::Components => "Components", } } } @@ -64,9 +62,6 @@ impl ThemePreview { ThemePreviewPage::Typography => { self.render_typography_page(window, cx).into_any_element() } - ThemePreviewPage::Components => { - self.render_components_page(window, cx).into_any_element() - } } } } @@ -392,28 +387,6 @@ impl ThemePreview { ) } - fn render_components_page(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let layer = ElevationIndex::Surface; - - v_flex() - .id("theme-preview-components") - .overflow_scroll() - .size_full() - .gap_2() - .child(Button::render_component_previews(window, cx)) - .child(Checkbox::render_component_previews(window, cx)) - .child(CheckboxWithLabel::render_component_previews(window, cx)) - .child(ContentGroup::render_component_previews(window, cx)) - .child(DecoratedIcon::render_component_previews(window, cx)) - .child(Facepile::render_component_previews(window, cx)) - .child(Icon::render_component_previews(window, cx)) - .child(IconDecoration::render_component_previews(window, cx)) - .child(KeybindingHint::render_component_previews(window, cx)) - .child(Indicator::render_component_previews(window, cx)) - .child(Switch::render_component_previews(window, cx)) - .child(Table::render_component_previews(window, cx)) - } - fn render_page_nav(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .id("theme-preview-nav") diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e4087fad4f50438e98bb89bf68b0e0087a10b707..e4e3a7c783ec71aa29ce8518b85d122899aecf80 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -148,6 +148,7 @@ actions!( Open, OpenFiles, OpenInTerminal, + OpenComponentPreview, ReloadActiveItem, SaveAs, SaveWithoutFormat, @@ -378,6 +379,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c pub fn init(app_state: Arc, cx: &mut App) { init_settings(cx); + component::init(); theme_preview::init(cx); cx.on_action(Workspace::close_global); @@ -4440,10 +4442,12 @@ impl Workspace { if let Some(focus_on) = focus_on { focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); } else { - self.panes - .last() - .unwrap() - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + if self.active_pane() == pane { + self.panes + .last() + .unwrap() + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + } } if self.last_active_center_pane == Some(pane.downgrade()) { self.last_active_center_pane = None; diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 0f76e6e4bbbfd503fd243f89fe59ef614884d4a1..8630f2019449faddace855728a0611f9e877c5e2 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -14,11 +14,12 @@ workspace = true [features] test-support = [ + "gpui/test-support", + "http_client/test-support", "language/test-support", "settings/test-support", "text/test-support", - "gpui/test-support", - "http_client/test-support", + "util/test-support", ] [dependencies] @@ -59,3 +60,4 @@ pretty_assertions.workspace = true rand.workspace = true rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index c2e0a1551e717c9ff9ed7a7b515334d3a9acaea8..08d55e0540e6df6e931a36a874336ad0e4ffa73b 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -213,12 +213,6 @@ impl Deref for RepositoryEntry { } } -impl AsRef for RepositoryEntry { - fn as_ref(&self) -> &Path { - &self.path - } -} - impl RepositoryEntry { pub fn branch(&self) -> Option> { self.branch.clone() @@ -326,33 +320,53 @@ impl RepositoryEntry { /// But if a sub-folder of a git repository is opened, this corresponds to the /// project root and the .git folder is located in a parent directory. #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)] -pub struct WorkDirectory { - path: Arc, - - /// If location_in_repo is set, it means the .git folder is external - /// and in a parent folder of the project root. - /// In that case, the work_directory field will point to the - /// project-root and location_in_repo contains the location of the - /// project-root in the repository. - /// - /// Example: - /// - /// my_root_folder/ <-- repository root - /// .git - /// my_sub_folder_1/ - /// project_root/ <-- Project root, Zed opened here - /// ... - /// - /// For this setup, the attributes will have the following values: - /// - /// work_directory: pointing to "" entry - /// location_in_repo: Some("my_sub_folder_1/project_root") - pub(crate) location_in_repo: Option>, +pub enum WorkDirectory { + InProject { + relative_path: Arc, + }, + AboveProject { + absolute_path: Arc, + location_in_repo: Arc, + }, } impl WorkDirectory { - pub fn path_key(&self) -> PathKey { - PathKey(self.path.clone()) + #[cfg(test)] + fn in_project(path: &str) -> Self { + let path = Path::new(path); + Self::InProject { + relative_path: path.into(), + } + } + + #[cfg(test)] + fn canonicalize(&self) -> Self { + match self { + WorkDirectory::InProject { relative_path } => WorkDirectory::InProject { + relative_path: relative_path.clone(), + }, + WorkDirectory::AboveProject { + absolute_path, + location_in_repo, + } => WorkDirectory::AboveProject { + absolute_path: absolute_path.canonicalize().unwrap().into(), + location_in_repo: location_in_repo.clone(), + }, + } + } + + pub fn is_above_project(&self) -> bool { + match self { + WorkDirectory::InProject { .. } => false, + WorkDirectory::AboveProject { .. } => true, + } + } + + fn path_key(&self) -> PathKey { + match self { + WorkDirectory::InProject { relative_path } => PathKey(relative_path.clone()), + WorkDirectory::AboveProject { .. } => PathKey(Path::new("").into()), + } } /// Returns true if the given path is a child of the work directory. @@ -360,9 +374,14 @@ impl WorkDirectory { /// Note that the path may not be a member of this repository, if there /// is a repository in a directory between these two paths /// external .git folder in a parent folder of the project root. + #[track_caller] pub fn directory_contains(&self, path: impl AsRef) -> bool { let path = path.as_ref(); - path.starts_with(&self.path) + debug_assert!(path.is_relative()); + match self { + WorkDirectory::InProject { relative_path } => path.starts_with(relative_path), + WorkDirectory::AboveProject { .. } => true, + } } /// relativize returns the given project path relative to the root folder of the @@ -371,53 +390,71 @@ impl WorkDirectory { /// of the project root folder, then the returned RepoPath is relative to the root /// of the repository and not a valid path inside the project. pub fn relativize(&self, path: &Path) -> Result { - let repo_path = if let Some(location_in_repo) = &self.location_in_repo { - // Avoid joining a `/` to location_in_repo in the case of a single-file worktree. - if path == Path::new("") { - RepoPath(location_in_repo.clone()) - } else { - location_in_repo.join(path).into() + // path is assumed to be relative to worktree root. + debug_assert!(path.is_relative()); + match self { + WorkDirectory::InProject { relative_path } => Ok(path + .strip_prefix(relative_path) + .map_err(|_| { + anyhow!( + "could not relativize {:?} against {:?}", + path, + relative_path + ) + })? + .into()), + WorkDirectory::AboveProject { + location_in_repo, .. + } => { + // Avoid joining a `/` to location_in_repo in the case of a single-file worktree. + if path == Path::new("") { + Ok(RepoPath(location_in_repo.clone())) + } else { + Ok(location_in_repo.join(path).into()) + } } - } else { - path.strip_prefix(&self.path) - .map_err(|_| anyhow!("could not relativize {:?} against {:?}", path, self.path))? - .into() - }; - Ok(repo_path) + } } /// This is the opposite operation to `relativize` above pub fn unrelativize(&self, path: &RepoPath) -> Option> { - if let Some(location) = &self.location_in_repo { - // If we fail to strip the prefix, that means this status entry is - // external to this worktree, and we definitely won't have an entry_id - path.strip_prefix(location).ok().map(Into::into) - } else { - Some(self.path.join(path).into()) + match self { + WorkDirectory::InProject { relative_path } => Some(relative_path.join(path).into()), + WorkDirectory::AboveProject { + location_in_repo, .. + } => { + // If we fail to strip the prefix, that means this status entry is + // external to this worktree, and we definitely won't have an entry_id + path.strip_prefix(location_in_repo).ok().map(Into::into) + } } } -} -impl Default for WorkDirectory { - fn default() -> Self { - Self { - path: Arc::from(Path::new("")), - location_in_repo: None, + pub fn display_name(&self) -> String { + match self { + WorkDirectory::InProject { relative_path } => relative_path.display().to_string(), + WorkDirectory::AboveProject { + absolute_path, + location_in_repo, + } => { + let num_of_dots = location_in_repo.components().count(); + + "../".repeat(num_of_dots) + + &absolute_path + .file_name() + .map(|s| s.to_string_lossy()) + .unwrap_or_default() + + "/" + } } } } -impl Deref for WorkDirectory { - type Target = Path; - - fn deref(&self) -> &Self::Target { - self.as_ref() - } -} - -impl AsRef for WorkDirectory { - fn as_ref(&self) -> &Path { - self.path.as_ref() +impl Default for WorkDirectory { + fn default() -> Self { + Self::InProject { + relative_path: Arc::from(Path::new("")), + } } } @@ -487,7 +524,7 @@ impl sum_tree::Item for LocalRepositoryEntry { fn summary(&self, _: &::Context) -> Self::Summary { PathSummary { - max_path: self.work_directory.path.clone(), + max_path: self.work_directory.path_key().0, item_summary: Unit, } } @@ -497,7 +534,7 @@ impl KeyedItem for LocalRepositoryEntry { type Key = PathKey; fn key(&self) -> Self::Key { - PathKey(self.work_directory.path.clone()) + self.work_directory.path_key() } } @@ -1395,16 +1432,7 @@ impl LocalWorktree { drop(barrier); } ScanState::RootUpdated { new_path } => { - if let Some(new_path) = new_path { - this.snapshot.git_repositories = Default::default(); - this.snapshot.ignores_by_parent_abs_path = Default::default(); - let root_name = new_path - .as_path() - .file_name() - .map_or(String::new(), |f| f.to_string_lossy().to_string()); - this.snapshot.update_abs_path(new_path, root_name); - } - this.restart_background_scanners(cx); + this.update_abs_path_and_refresh(new_path, cx); } } cx.notify(); @@ -1844,6 +1872,10 @@ impl LocalWorktree { })) } + /// Rename an entry. + /// + /// `new_path` is the new relative path to the worktree root. + /// If the root entry is renamed then `new_path` is the new root name instead. fn rename_entry( &self, entry_id: ProjectEntryId, @@ -1856,8 +1888,18 @@ impl LocalWorktree { }; let new_path = new_path.into(); let abs_old_path = self.absolutize(&old_path); - let Ok(abs_new_path) = self.absolutize(&new_path) else { - return Task::ready(Err(anyhow!("absolutizing path {new_path:?}"))); + + let is_root_entry = self.root_entry().is_some_and(|e| e.id == entry_id); + let abs_new_path = if is_root_entry { + let Some(root_parent_path) = self.abs_path().parent() else { + return Task::ready(Err(anyhow!("no parent for path {:?}", self.abs_path))); + }; + root_parent_path.join(&new_path) + } else { + let Ok(absolutize_path) = self.absolutize(&new_path) else { + return Task::ready(Err(anyhow!("absolutizing path {new_path:?}"))); + }; + absolutize_path }; let abs_path = abs_new_path.clone(); let fs = self.fs.clone(); @@ -1891,9 +1933,19 @@ impl LocalWorktree { rename.await?; Ok(this .update(&mut cx, |this, cx| { - this.as_local_mut() - .unwrap() - .refresh_entry(new_path.clone(), Some(old_path), cx) + let local = this.as_local_mut().unwrap(); + if is_root_entry { + // We eagerly update `abs_path` and refresh this worktree. + // Otherwise, the FS watcher would do it on the `RootUpdated` event, + // but with a noticeable delay, so we handle it proactively. + local.update_abs_path_and_refresh( + Some(SanitizedPath::from(abs_path.clone())), + cx, + ); + Task::ready(Ok(this.root_entry().cloned())) + } else { + local.refresh_entry(new_path.clone(), Some(old_path), cx) + } })? .await? .map(CreatedEntry::Included) @@ -2158,6 +2210,23 @@ impl LocalWorktree { self.share_private_files = true; self.restart_background_scanners(cx); } + + fn update_abs_path_and_refresh( + &mut self, + new_path: Option, + cx: &Context, + ) { + if let Some(new_path) = new_path { + self.snapshot.git_repositories = Default::default(); + self.snapshot.ignores_by_parent_abs_path = Default::default(); + let root_name = new_path + .as_path() + .file_name() + .map_or(String::new(), |f| f.to_string_lossy().to_string()); + self.snapshot.update_abs_path(new_path, root_name); + } + self.restart_background_scanners(cx); + } } impl RemoteWorktree { @@ -2574,12 +2643,11 @@ impl Snapshot { self.repositories.insert_or_replace( RepositoryEntry { work_directory_id, - work_directory: WorkDirectory { - path: work_dir_entry.path.clone(), - // When syncing repository entries from a peer, we don't need - // the location_in_repo field, since git operations don't happen locally - // anyway. - location_in_repo: None, + // When syncing repository entries from a peer, we don't need + // the location_in_repo field, since git operations don't happen locally + // anyway. + work_directory: WorkDirectory::InProject { + relative_path: work_dir_entry.path.clone(), }, branch: repository.branch.map(Into::into), statuses_by_path: statuses, @@ -2690,23 +2758,13 @@ impl Snapshot { &self.repositories } - pub fn repositories_with_abs_paths( - &self, - ) -> impl '_ + Iterator { - let base = self.abs_path(); - self.repositories.iter().map(|repo| { - let path = repo.work_directory.location_in_repo.as_deref(); - let path = path.unwrap_or(repo.work_directory.as_ref()); - (repo, base.join(path)) - }) - } - /// Get the repository whose work directory corresponds to the given path. pub(crate) fn repository(&self, work_directory: PathKey) -> Option { self.repositories.get(&work_directory, &()).cloned() } /// Get the repository whose work directory contains the given path. + #[track_caller] pub fn repository_for_path(&self, path: &Path) -> Option<&RepositoryEntry> { self.repositories .iter() @@ -2716,6 +2774,7 @@ impl Snapshot { /// Given an ordered iterator of entries, returns an iterator of those entries, /// along with their containing git repository. + #[track_caller] pub fn entries_with_repositories<'a>( &'a self, entries: impl 'a + Iterator, @@ -2817,6 +2876,7 @@ impl Snapshot { pub fn entry_for_path(&self, path: impl AsRef) -> Option<&Entry> { let path = path.as_ref(); + debug_assert!(path.is_relative()); self.traverse_from_path(true, true, true, path) .entry() .and_then(|entry| { @@ -3080,7 +3140,7 @@ impl LocalSnapshot { let work_dir_paths = self .repositories .iter() - .map(|repo| repo.work_directory.path.clone()) + .map(|repo| repo.work_directory.path_key()) .collect::>(); assert_eq!(dotgit_paths.len(), work_dir_paths.len()); assert_eq!(self.repositories.iter().count(), work_dir_paths.len()); @@ -3288,7 +3348,7 @@ impl BackgroundScannerState { .git_repositories .retain(|id, _| removed_ids.binary_search(id).is_err()); self.snapshot.repositories.retain(&(), |repository| { - !repository.work_directory.starts_with(path) + !repository.work_directory.path_key().0.starts_with(path) }); #[cfg(test)] @@ -3326,20 +3386,26 @@ impl BackgroundScannerState { } }; - self.insert_git_repository_for_path(work_dir_path, dot_git_path, None, fs, watcher) + self.insert_git_repository_for_path( + WorkDirectory::InProject { + relative_path: work_dir_path, + }, + dot_git_path, + fs, + watcher, + ) } fn insert_git_repository_for_path( &mut self, - work_dir_path: Arc, + work_directory: WorkDirectory, dot_git_path: Arc, - location_in_repo: Option>, fs: &dyn Fs, watcher: &dyn Watcher, ) -> Option { let work_dir_id = self .snapshot - .entry_for_path(work_dir_path.clone()) + .entry_for_path(work_directory.path_key().0) .map(|entry| entry.id)?; if self.snapshot.git_repositories.get(&work_dir_id).is_some() { @@ -3373,10 +3439,6 @@ impl BackgroundScannerState { }; log::trace!("constructed libgit2 repo in {:?}", t0.elapsed()); - let work_directory = WorkDirectory { - path: work_dir_path.clone(), - location_in_repo, - }; if let Some(git_hosting_provider_registry) = self.git_hosting_provider_registry.clone() { git_hosting_providers::register_additional_providers( @@ -3839,7 +3901,7 @@ impl sum_tree::Item for RepositoryEntry { fn summary(&self, _: &::Context) -> Self::Summary { PathSummary { - max_path: self.work_directory.path.clone(), + max_path: self.work_directory.path_key().0, item_summary: Unit, } } @@ -3849,7 +3911,7 @@ impl sum_tree::KeyedItem for RepositoryEntry { type Key = PathKey; fn key(&self) -> Self::Key { - PathKey(self.work_directory.path.clone()) + self.work_directory.path_key() } } @@ -4088,7 +4150,7 @@ impl<'a> sum_tree::Dimension<'a, PathEntrySummary> for ProjectEntryId { } } -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct PathKey(Arc); impl Default for PathKey { @@ -4167,15 +4229,15 @@ impl BackgroundScanner { // We associate the external git repo with our root folder and // also mark where in the git repo the root folder is located. self.state.lock().insert_git_repository_for_path( - Path::new("").into(), - ancestor_dot_git.into(), - Some( - root_abs_path + WorkDirectory::AboveProject { + absolute_path: ancestor.into(), + location_in_repo: root_abs_path .as_path() .strip_prefix(ancestor) .unwrap() .into(), - ), + }, + ancestor_dot_git.into(), self.fs.as_ref(), self.watcher.as_ref(), ); @@ -4401,6 +4463,14 @@ impl BackgroundScanner { return false; }; + if abs_path.0.file_name() == Some(*GITIGNORE) { + for (_, repo) in snapshot.git_repositories.iter().filter(|(_, repo)| repo.directory_contains(&relative_path)) { + if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.dot_git_dir_abs_path.as_ref()) { + dot_git_abs_paths.push(repo.dot_git_dir_abs_path.to_path_buf()); + } + } + } + let parent_dir_is_loaded = relative_path.parent().map_or(true, |parent| { snapshot .entry_for_path(parent) @@ -4984,7 +5054,7 @@ impl BackgroundScanner { snapshot .snapshot .repositories - .remove(&PathKey(repository.work_directory.path.clone()), &()); + .remove(&repository.work_directory.path_key(), &()); return Some(()); } } @@ -5169,8 +5239,12 @@ impl BackgroundScanner { let local_repository = match existing_repository_entry { None => { + let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) + else { + return; + }; match state.insert_git_repository( - dot_git_dir.into(), + relative.into(), self.fs.as_ref(), self.watcher.as_ref(), ) { @@ -5274,7 +5348,7 @@ impl BackgroundScanner { fn update_git_statuses(&self, job: UpdateGitStatusesJob) { log::trace!( "updating git statuses for repo {:?}", - job.local_repository.work_directory.path + job.local_repository.work_directory.display_name() ); let t0 = Instant::now(); @@ -5288,7 +5362,7 @@ impl BackgroundScanner { }; log::trace!( "computed git statuses for repo {:?} in {:?}", - job.local_repository.work_directory.path, + job.local_repository.work_directory.display_name(), t0.elapsed() ); @@ -5299,8 +5373,8 @@ impl BackgroundScanner { let Some(mut repository) = snapshot.repository(job.local_repository.work_directory.path_key()) else { - log::error!("Got an UpdateGitStatusesJob for a repository that isn't in the snapshot"); - debug_assert!(false); + // happens when a folder is deleted + log::debug!("Got an UpdateGitStatusesJob for a repository that isn't in the snapshot"); return; }; @@ -5352,7 +5426,7 @@ impl BackgroundScanner { log::trace!( "applied git status updates for repo {:?} in {:?}", - job.local_repository.work_directory.path, + job.local_repository.work_directory.display_name(), t0.elapsed(), ); } diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 2cee728aec89e40500700c182ed617400085739e..f4e6da23455c5b10f44a337c2f011a80656e534d 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1,6 +1,6 @@ use crate::{ - worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot, Worktree, - WorktreeModelHandle, + worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot, + WorkDirectory, Worktree, WorktreeModelHandle, }; use anyhow::Result; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; @@ -2200,7 +2200,10 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); let repo = tree.repositories().iter().next().unwrap(); - assert_eq!(repo.path.as_ref(), Path::new("projects/project1")); + assert_eq!( + repo.work_directory, + WorkDirectory::in_project("projects/project1") + ); assert_eq!( tree.status_for_file(Path::new("projects/project1/a")), Some(StatusCode::Modified.worktree()), @@ -2221,7 +2224,10 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); let repo = tree.repositories().iter().next().unwrap(); - assert_eq!(repo.path.as_ref(), Path::new("projects/project2")); + assert_eq!( + repo.work_directory, + WorkDirectory::in_project("projects/project2") + ); assert_eq!( tree.status_for_file(Path::new("projects/project2/a")), Some(StatusCode::Modified.worktree()), @@ -2275,12 +2281,15 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) { assert!(tree.repository_for_path("c.txt".as_ref()).is_none()); let repo = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap(); - assert_eq!(repo.path.as_ref(), Path::new("dir1")); + assert_eq!(repo.work_directory, WorkDirectory::in_project("dir1")); let repo = tree .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref()) .unwrap(); - assert_eq!(repo.path.as_ref(), Path::new("dir1/deps/dep1")); + assert_eq!( + repo.work_directory, + WorkDirectory::in_project("dir1/deps/dep1") + ); let entries = tree.files(false, 0); @@ -2289,7 +2298,7 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) { .map(|(entry, repo)| { ( entry.path.as_ref(), - repo.map(|repo| repo.path.to_path_buf()), + repo.map(|repo| repo.work_directory.clone()), ) }) .collect::>(); @@ -2300,9 +2309,12 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) { (Path::new("c.txt"), None), ( Path::new("dir1/deps/dep1/src/a.txt"), - Some(Path::new("dir1/deps/dep1").into()) + Some(WorkDirectory::in_project("dir1/deps/dep1")) + ), + ( + Path::new("dir1/src/b.txt"), + Some(WorkDirectory::in_project("dir1")) ), - (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())), ] ); }); @@ -2408,8 +2420,10 @@ async fn test_file_status(cx: &mut TestAppContext) { let snapshot = tree.snapshot(); assert_eq!(snapshot.repositories().iter().count(), 1); let repo_entry = snapshot.repositories().iter().next().unwrap(); - assert_eq!(repo_entry.path.as_ref(), Path::new("project")); - assert!(repo_entry.location_in_repo.is_none()); + assert_eq!( + repo_entry.work_directory, + WorkDirectory::in_project("project") + ); assert_eq!( snapshot.status_for_file(project_path.join(B_TXT)), @@ -2760,15 +2774,14 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { let snapshot = tree.snapshot(); assert_eq!(snapshot.repositories().iter().count(), 1); let repo = snapshot.repositories().iter().next().unwrap(); - // Path is blank because the working directory of - // the git repository is located at the root of the project - assert_eq!(repo.path.as_ref(), Path::new("")); - - // This is the missing path between the root of the project (sub-folder-2) and its - // location relative to the root of the repository. assert_eq!( - repo.location_in_repo, - Some(Arc::from(Path::new("sub-folder-1/sub-folder-2"))) + repo.work_directory.canonicalize(), + WorkDirectory::AboveProject { + absolute_path: Arc::from(root.path().join("my-repo").canonicalize().unwrap()), + location_in_repo: Arc::from(Path::new(util::separator!( + "sub-folder-1/sub-folder-2" + ))) + } ); assert_eq!(snapshot.status_for_file("c.txt"), None); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 080c0d6c2500b9164d549fa963db1cec113c935b..1ec294a2dc8480c6bdcf296d253897beb833105a 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -39,6 +39,7 @@ collab_ui.workspace = true collections.workspace = true command_palette.workspace = true command_palette_hooks.workspace = true +component_preview.workspace = true copilot.workspace = true db.workspace = true diagnostics.workspace = true @@ -54,8 +55,8 @@ file_icons.workspace = true fs.workspace = true futures.workspace = true git.workspace = true -git_ui.workspace = true git_hosting_providers.workspace = true +git_ui.workspace = true go_to_line.workspace = true gpui = { workspace = true, features = ["wayland", "x11", "font-kit"] } gpui_tokio.workspace = true @@ -126,7 +127,6 @@ url.workspace = true urlencoding = "2.1.2" util.workspace = true uuid.workspace = true -vcs_menu.workspace = true vim.workspace = true vim_mode_setting.workspace = true welcome.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 019af54c541a0d42b90de72c19af624b9d7c26ed..cbd2519e602a362d9ba730e5a6785c022e0e5c70 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -490,6 +490,7 @@ fn main() { project_panel::init(Assets, cx); git_ui::git_panel::init(cx); outline_panel::init(Assets, cx); + component_preview::init(cx); tasks_ui::init(cx); snippets_ui::init(cx); channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx); @@ -505,7 +506,6 @@ fn main() { notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); git_ui::init(cx); - vcs_menu::init(cx); feedback::init(cx); markdown_preview::init(cx); welcome::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 9d4cb83e08bddcdb6c686e867baf8bce46bf7efa..7fe30db8fec7250d6f6c09c4c683d081c559f0de 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1217,25 +1217,29 @@ fn show_keymap_migration_notification_if_needed( if !KeymapFile::should_migrate_keymap(keymap_file) { return false; } - show_app_notification(notification_id, cx, move |cx| { - cx.new(move |_cx| { - let message = "A newer version of Zed has simplified several keymaps. Your existing keymaps may be deprecated. You can migrate them by clicking below. A backup will be created in your home directory."; - let button_text = "Backup and Migrate Keymap"; - MessageNotification::new_from_builder(move |_, _| { - gpui::div().text_xs().child(message).into_any() - }) - .primary_message(button_text) - .primary_on_click(move |_, cx| { - let fs = ::global(cx); - cx.spawn(move |weak_notification, mut cx| async move { - KeymapFile::migrate_keymap(fs).await.ok(); - weak_notification.update(&mut cx, |_, cx| { + let message = MarkdownString(format!( + "Keymap migration needed, as the format for some actions has changed. \ + You can migrate your keymap by clicking below. A backup will be created at {}.", + MarkdownString::inline_code(&paths::keymap_backup_file().to_string_lossy()) + )); + show_markdown_app_notification( + notification_id, + message, + "Backup and Migrate Keymap".into(), + move |_, cx| { + let fs = ::global(cx); + cx.spawn(move |weak_notification, mut cx| async move { + KeymapFile::migrate_keymap(fs).await.ok(); + weak_notification + .update(&mut cx, |_, cx| { cx.emit(DismissEvent); - }).ok(); - }).detach(); + }) + .ok(); }) - }) - }); + .detach(); + }, + cx, + ); return true; } @@ -1247,33 +1251,55 @@ fn show_settings_migration_notification_if_needed( if !SettingsStore::should_migrate_settings(&settings) { return; } - show_app_notification(notification_id, cx, move |cx| { - cx.new(move |_cx| { - let message = "A newer version of Zed has updated some settings. Your existing settings may be deprecated. You can migrate them by clicking below. A backup will be created in your home directory."; - let button_text = "Backup and Migrate Settings"; - MessageNotification::new_from_builder(move |_, _| { - gpui::div().text_xs().child(message).into_any() - }) - .primary_message(button_text) - .primary_on_click(move |_, cx| { - let fs = ::global(cx); - cx.update_global(|store: &mut SettingsStore, _| store.migrate_settings(fs)); - cx.emit(DismissEvent); - }) - }) - }); + let message = MarkdownString(format!( + "Settings migration needed, as the format for some settings has changed. \ + You can migrate your settings by clicking below. A backup will be created at {}.", + MarkdownString::inline_code(&paths::settings_backup_file().to_string_lossy()) + )); + show_markdown_app_notification( + notification_id, + message, + "Backup and Migrate Settings".into(), + move |_, cx| { + let fs = ::global(cx); + cx.update_global(|store: &mut SettingsStore, _| store.migrate_settings(fs)); + cx.emit(DismissEvent); + }, + cx, + ); } fn show_keymap_file_load_error( notification_id: NotificationId, - markdown_error_message: MarkdownString, + error_message: MarkdownString, cx: &mut App, ) { + show_markdown_app_notification( + notification_id.clone(), + error_message, + "Open Keymap File".into(), + |window, cx| { + window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); + cx.emit(DismissEvent); + }, + cx, + ) +} + +fn show_markdown_app_notification( + notification_id: NotificationId, + message: MarkdownString, + primary_button_message: SharedString, + primary_button_on_click: F, + cx: &mut App, +) where + F: 'static + Send + Sync + Fn(&mut Window, &mut Context), +{ let parsed_markdown = cx.background_executor().spawn(async move { let file_location_directory = None; let language_registry = None; markdown_preview::markdown_parser::parse_markdown( - &markdown_error_message.0, + &message.0, file_location_directory, language_registry, ) @@ -1282,10 +1308,14 @@ fn show_keymap_file_load_error( cx.spawn(move |cx| async move { let parsed_markdown = Arc::new(parsed_markdown.await); + let primary_button_message = primary_button_message.clone(); + let primary_button_on_click = Arc::new(primary_button_on_click); cx.update(|cx| { show_app_notification(notification_id, cx, move |cx| { let workspace_handle = cx.entity().downgrade(); let parsed_markdown = parsed_markdown.clone(); + let primary_button_message = primary_button_message.clone(); + let primary_button_on_click = primary_button_on_click.clone(); cx.new(move |_cx| { MessageNotification::new_from_builder(move |window, cx| { gpui::div() @@ -1298,11 +1328,8 @@ fn show_keymap_file_load_error( )) .into_any() }) - .primary_message("Open Keymap File") - .primary_on_click(|window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); - cx.emit(DismissEvent); - }) + .primary_message(primary_button_message) + .primary_on_click_arc(primary_button_on_click) }) }) }) diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 67161de75f3e95dbd87ccd792736dd257d2d42f6..5f2b98d444e3a8f6da2035e3db03498399bbf2b7 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -168,15 +168,13 @@ impl Render for QuickActionBar { let focus = editor.focus_handle(cx); PopoverMenu::new("editor-selections-dropdown") - .trigger( + .trigger_with_tooltip( IconButton::new("toggle_editor_selections_icon", IconName::CursorIBeam) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) - .toggle_state(self.toggle_selections_handle.is_deployed()) - .when(!self.toggle_selections_handle.is_deployed(), |this| { - this.tooltip(Tooltip::text("Selection Controls")) - }), + .toggle_state(self.toggle_selections_handle.is_deployed()), + Tooltip::text("Selection Controls"), ) .with_handle(self.toggle_selections_handle.clone()) .anchor(Corner::TopRight) @@ -213,38 +211,84 @@ impl Render for QuickActionBar { }) }); + let editor_focus_handle = editor.focus_handle(cx); let editor = editor.downgrade(); let editor_settings_dropdown = { let vim_mode_enabled = VimModeSetting::get_global(cx).0; PopoverMenu::new("editor-settings") - .trigger( + .trigger_with_tooltip( IconButton::new("toggle_editor_settings_icon", IconName::Sliders) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) - .toggle_state(self.toggle_settings_handle.is_deployed()) - .when(!self.toggle_settings_handle.is_deployed(), |this| { - this.tooltip(Tooltip::text("Editor Controls")) - }), + .toggle_state(self.toggle_settings_handle.is_deployed()), + Tooltip::text("Editor Controls"), ) .anchor(Corner::TopRight) .with_handle(self.toggle_settings_handle.clone()) .menu(move |window, cx| { - let menu = ContextMenu::build(window, cx, |mut menu, _, _| { - if supports_inlay_hints { + let menu = ContextMenu::build(window, cx, { + let focus_handle = editor_focus_handle.clone(); + |mut menu, _, _| { + menu = menu.context(focus_handle); + + if supports_inlay_hints { + menu = menu.toggleable_entry( + "Inlay Hints", + inlay_hints_enabled, + IconPosition::Start, + Some(editor::actions::ToggleInlayHints.boxed_clone()), + { + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_inlay_hints( + &editor::actions::ToggleInlayHints, + window, + cx, + ); + }) + .ok(); + } + }, + ); + } + + menu = menu.toggleable_entry( + "Selection Menu", + selection_menu_enabled, + IconPosition::Start, + Some(editor::actions::ToggleSelectionMenu.boxed_clone()), + { + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_selection_menu( + &editor::actions::ToggleSelectionMenu, + window, + cx, + ) + }) + .ok(); + } + }, + ); + menu = menu.toggleable_entry( - "Inlay Hints", - inlay_hints_enabled, + "Auto Signature Help", + auto_signature_help_enabled, IconPosition::Start, - Some(editor::actions::ToggleInlayHints.boxed_clone()), + Some(editor::actions::ToggleAutoSignatureHelp.boxed_clone()), { let editor = editor.clone(); move |window, cx| { editor .update(cx, |editor, cx| { - editor.toggle_inlay_hints( - &editor::actions::ToggleInlayHints, + editor.toggle_auto_signature_help_menu( + &editor::actions::ToggleAutoSignatureHelp, window, cx, ); @@ -253,138 +297,96 @@ impl Render for QuickActionBar { } }, ); - } - menu = menu.toggleable_entry( - "Selection Menu", - selection_menu_enabled, - IconPosition::Start, - Some(editor::actions::ToggleSelectionMenu.boxed_clone()), - { - let editor = editor.clone(); - move |window, cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_selection_menu( - &editor::actions::ToggleSelectionMenu, - window, - cx, - ) - }) - .ok(); - } - }, - ); - - menu = menu.toggleable_entry( - "Auto Signature Help", - auto_signature_help_enabled, - IconPosition::Start, - Some(editor::actions::ToggleAutoSignatureHelp.boxed_clone()), - { - let editor = editor.clone(); - move |window, cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_auto_signature_help_menu( - &editor::actions::ToggleAutoSignatureHelp, - window, - cx, - ); - }) - .ok(); - } - }, - ); - - let mut inline_completion_entry = ContextMenuEntry::new("Edit Predictions") - .toggleable(IconPosition::Start, inline_completion_enabled && show_inline_completions) - .disabled(!inline_completion_enabled) - .action(Some( - editor::actions::ToggleEditPrediction.boxed_clone(), - )).handler({ - let editor = editor.clone(); - move |window, cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_inline_completions( - &editor::actions::ToggleEditPrediction, - window, - cx, - ); - }) - .ok(); - } - }); - if !inline_completion_enabled { - inline_completion_entry = inline_completion_entry.documentation_aside(|_| { - Label::new("You can't toggle edit predictions for this file as it is within the excluded files list.").into_any_element() - }); - } + let mut inline_completion_entry = ContextMenuEntry::new("Edit Predictions") + .toggleable(IconPosition::Start, inline_completion_enabled && show_inline_completions) + .disabled(!inline_completion_enabled) + .action(Some( + editor::actions::ToggleEditPrediction.boxed_clone(), + )).handler({ + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_inline_completions( + &editor::actions::ToggleEditPrediction, + window, + cx, + ); + }) + .ok(); + } + }); + if !inline_completion_enabled { + inline_completion_entry = inline_completion_entry.documentation_aside(|_| { + Label::new("You can't toggle edit predictions for this file as it is within the excluded files list.").into_any_element() + }); + } + + menu = menu.item(inline_completion_entry); + + menu = menu.separator(); + + menu = menu.toggleable_entry( + "Inline Git Blame", + git_blame_inline_enabled, + IconPosition::Start, + Some(editor::actions::ToggleGitBlameInline.boxed_clone()), + { + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_git_blame_inline( + &editor::actions::ToggleGitBlameInline, + window, + cx, + ) + }) + .ok(); + } + }, + ); - menu = menu.item(inline_completion_entry); - - menu = menu.separator(); - - menu = menu.toggleable_entry( - "Inline Git Blame", - git_blame_inline_enabled, - IconPosition::Start, - Some(editor::actions::ToggleGitBlameInline.boxed_clone()), - { - let editor = editor.clone(); - move |window, cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_git_blame_inline( - &editor::actions::ToggleGitBlameInline, - window, - cx, - ) - }) - .ok(); - } - }, - ); - - menu = menu.toggleable_entry( - "Column Git Blame", - show_git_blame_gutter, - IconPosition::Start, - Some(editor::actions::ToggleGitBlame.boxed_clone()), - { - let editor = editor.clone(); - move |window, cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_git_blame( - &editor::actions::ToggleGitBlame, - window, - cx, - ) - }) - .ok(); - } - }, - ); - - menu = menu.separator(); - - menu = menu.toggleable_entry( - "Vim Mode", - vim_mode_enabled, - IconPosition::Start, - None, - { - move |window, cx| { - let new_value = !vim_mode_enabled; - VimModeSetting::override_global(VimModeSetting(new_value), cx); - window.refresh(); - } - }, - ); - - menu + menu = menu.toggleable_entry( + "Column Git Blame", + show_git_blame_gutter, + IconPosition::Start, + Some(editor::actions::ToggleGitBlame.boxed_clone()), + { + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_git_blame( + &editor::actions::ToggleGitBlame, + window, + cx, + ) + }) + .ok(); + } + }, + ); + + menu = menu.separator(); + + menu = menu.toggleable_entry( + "Vim Mode", + vim_mode_enabled, + IconPosition::Start, + None, + { + move |window, cx| { + let new_value = !vim_mode_enabled; + VimModeSetting::override_global(VimModeSetting(new_value), cx); + window.refresh(); + } + }, + ); + + menu + } }); Some(menu) }) diff --git a/crates/zed/src/zed/quick_action_bar/repl_menu.rs b/crates/zed/src/zed/quick_action_bar/repl_menu.rs index 6e7f57ad947ddc3be79d0ff47d2fef85378cb8a0..51ed0af6b88c8b5b97e9fb6d360f6b159c07c6d9 100644 --- a/crates/zed/src/zed/quick_action_bar/repl_menu.rs +++ b/crates/zed/src/zed/quick_action_bar/repl_menu.rs @@ -209,16 +209,16 @@ impl QuickActionBar { }) .into() }) - .trigger( + .trigger_with_tooltip( ButtonLike::new_rounded_right(element_id("dropdown")) .child( Icon::new(IconName::ChevronDownSmall) .size(IconSize::XSmall) .color(Color::Muted), ) - .tooltip(Tooltip::text("REPL Menu")) .width(rems(1.).into()) .disabled(menu_state.popover_disabled), + Tooltip::text("REPL Menu"), ); let button = ButtonLike::new_rounded_left("toggle_repl_icon") @@ -343,8 +343,8 @@ impl QuickActionBar { .color(Color::Muted) .size(IconSize::XSmall), ), - ) - .tooltip(Tooltip::text("Select Kernel")), + ), + Tooltip::text("Select Kernel"), ) .with_handle(menu_handle.clone()) .into_any_element() diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 2299bf58bc2c7bd537f7af5bd65e1a6eec3ab870..08ec86afa09fe42edc6e4affbce0e917cea450ec 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -47,10 +47,10 @@ actions!( ] ); -pub mod branches { - use gpui::actions; +pub mod git { + use gpui::action_with_deprecated_aliases; - actions!(branches, [OpenRecent]); + action_with_deprecated_aliases!(git, Branch, ["branches::OpenRecent"]); } pub mod command_palette { diff --git a/crates/zeta/src/input_excerpt.rs b/crates/zeta/src/input_excerpt.rs new file mode 100644 index 0000000000000000000000000000000000000000..7171facaffdad66acd4438c23e684ea51eb306bb --- /dev/null +++ b/crates/zeta/src/input_excerpt.rs @@ -0,0 +1,238 @@ +use crate::{ + tokens_for_bytes, CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER, + START_OF_FILE_MARKER, +}; +use language::{BufferSnapshot, Point}; +use std::{fmt::Write, ops::Range}; + +#[derive(Debug)] +pub struct InputExcerpt { + pub editable_range: Range, + pub prompt: String, + pub speculated_output: String, +} + +pub fn excerpt_for_cursor_position( + position: Point, + path: &str, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> InputExcerpt { + let mut scope_range = position..position; + let mut remaining_edit_tokens = editable_region_token_limit; + + while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) { + let parent_tokens = tokens_for_bytes(parent.byte_range().len()); + let parent_point_range = Point::new( + parent.start_position().row as u32, + parent.start_position().column as u32, + ) + ..Point::new( + parent.end_position().row as u32, + parent.end_position().column as u32, + ); + if parent_point_range == scope_range { + break; + } else if parent_tokens <= editable_region_token_limit { + scope_range = parent_point_range; + remaining_edit_tokens = editable_region_token_limit - parent_tokens; + } else { + break; + } + } + + let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens); + let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); + + let mut prompt = String::new(); + let mut speculated_output = String::new(); + + writeln!(&mut prompt, "```{path}").unwrap(); + if context_range.start == Point::zero() { + writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap(); + } + + for chunk in snapshot.chunks(context_range.start..editable_range.start, false) { + prompt.push_str(chunk.text); + } + + push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); + push_editable_range( + position, + snapshot, + editable_range.clone(), + &mut speculated_output, + ); + + for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n```").unwrap(); + + InputExcerpt { + editable_range, + prompt, + speculated_output, + } +} + +fn push_editable_range( + cursor_position: Point, + snapshot: &BufferSnapshot, + editable_range: Range, + prompt: &mut String, +) { + writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap(); + for chunk in snapshot.chunks(editable_range.start..cursor_position, false) { + prompt.push_str(chunk.text); + } + prompt.push_str(CURSOR_MARKER); + for chunk in snapshot.chunks(cursor_position..editable_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); +} + +fn expand_range( + snapshot: &BufferSnapshot, + range: Range, + mut remaining_tokens: usize, +) -> Range { + let mut expanded_range = range.clone(); + expanded_range.start.column = 0; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + loop { + let mut expanded = false; + + if remaining_tokens > 0 && expanded_range.start.row > 0 { + expanded_range.start.row -= 1; + let line_tokens = + tokens_for_bytes(snapshot.line_len(expanded_range.start.row) as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row { + expanded_range.end.row += 1; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + let line_tokens = tokens_for_bytes(expanded_range.end.column as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if !expanded { + break; + } + } + expanded_range +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{App, AppContext}; + use indoc::indoc; + use language::{Buffer, Language, LanguageConfig, LanguageMatcher}; + use std::sync::Arc; + + #[gpui::test] + fn test_excerpt_for_cursor_position(cx: &mut App) { + let text = indoc! {r#" + fn foo() { + let x = 42; + println!("Hello, world!"); + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + return sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.gen_range(1..101)); + } + numbers + } + "#}; + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.read(cx).snapshot(); + + // Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion + // when a larger scope doesn't fit the editable region. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + let x = 42; + println!("Hello, world!"); + <|editable_region_start|> + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + <|editable_region_end|> + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + ```"#} + ); + + // The `bar` function won't fit within the editable region, so we resort to line-based expansion. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + fn bar() { + let x = 42; + let mut sum = 0; + <|editable_region_start|> + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + <|editable_region_end|> + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.gen_range(1..101)); + ```"#} + ); + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + } +} diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 7741e52f3102be22d6452c21835e215e9d037a60..14cd32b300f76508044cd00bab1f6fe8427ba75c 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -1,5 +1,6 @@ mod completion_diff_element; mod init; +mod input_excerpt; mod license_detection; mod onboarding_banner; mod onboarding_modal; @@ -25,9 +26,8 @@ use gpui::{ actions, App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, Subscription, Task, }; use http_client::{HttpClient, Method}; -use language::{ - Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, Point, ToOffset, ToPoint, -}; +use input_excerpt::excerpt_for_cursor_position; +use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, ToOffset, ToPoint}; use language_models::LlmApiToken; use postage::watch; use project::Project; @@ -57,38 +57,13 @@ const EDITABLE_REGION_END_MARKER: &'static str = "<|editable_region_end|>"; const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1); const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; -// TODO(mgsloan): more systematic way to choose or tune these fairly arbitrary constants? - -/// Typical number of string bytes per token for the purposes of limiting model input. This is -/// intentionally low to err on the side of underestimating limits. -const BYTES_PER_TOKEN_GUESS: usize = 3; - -/// Output token limit, used to inform the size of the input. A copy of this constant is also in -/// `crates/collab/src/llm.rs`. -const MAX_OUTPUT_TOKENS: usize = 2048; - -/// Total bytes limit for editable region of buffer excerpt. -/// -/// The number of output tokens is relevant to the size of the input excerpt because the model is -/// tasked with outputting a modified excerpt. `2/3` is chosen so that there are some output tokens -/// remaining for the model to specify insertions. -const BUFFER_EXCERPT_BYTE_LIMIT: usize = (MAX_OUTPUT_TOKENS * 2 / 3) * BYTES_PER_TOKEN_GUESS; +const MAX_CONTEXT_TOKENS: usize = 100; +const MAX_REWRITE_TOKENS: usize = 300; +const MAX_EVENT_TOKENS: usize = 400; -/// Total line limit for editable region of buffer excerpt. -const BUFFER_EXCERPT_LINE_LIMIT: u32 = 64; - -/// Note that this is not the limit for the overall prompt, just for the inputs to the template -/// instantiated in `crates/collab/src/llm.rs`. -const TOTAL_BYTE_LIMIT: usize = BUFFER_EXCERPT_BYTE_LIMIT * 2; - -/// Maximum number of events to include in the prompt. +/// Maximum number of events to track. const MAX_EVENT_COUNT: usize = 16; -/// Maximum number of string bytes in a single event. Arbitrarily choosing this to be 4x the size of -/// equally splitting up the the remaining bytes after the largest possible buffer excerpt. -const PER_EVENT_BYTE_LIMIT: usize = - (TOTAL_BYTE_LIMIT - BUFFER_EXCERPT_BYTE_LIMIT) / MAX_EVENT_COUNT * 4; - actions!(edit_prediction, [ClearHistory]); #[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] @@ -106,12 +81,6 @@ impl std::fmt::Display for InlineCompletionId { } } -impl InlineCompletionId { - fn new() -> Self { - Self(Uuid::new_v4()) - } -} - #[derive(Clone)] struct ZetaGlobal(Entity); @@ -214,7 +183,8 @@ pub struct Zeta { data_collection_choice: Entity, llm_token: LlmApiToken, _llm_token_subscription: Subscription, - tos_accepted: bool, // Terms of service accepted + /// Whether the terms of service have been accepted. + tos_accepted: bool, _user_store_subscription: Subscription, license_detection_watchers: HashMap>, } @@ -418,7 +388,8 @@ impl Zeta { struct BackgroundValues { input_events: String, input_excerpt: String, - excerpt_range: Range, + speculated_output: String, + editable_range: Range, input_outline: String, } @@ -429,32 +400,21 @@ impl Zeta { let path = path.clone(); async move { let path = path.to_string_lossy(); - let (excerpt_range, excerpt_len_guess) = excerpt_range_for_position( + let input_excerpt = excerpt_for_cursor_position( cursor_point, - BUFFER_EXCERPT_BYTE_LIMIT, - BUFFER_EXCERPT_LINE_LIMIT, - &path, - &snapshot, - )?; - let input_excerpt = prompt_for_excerpt( - cursor_offset, - &excerpt_range, - excerpt_len_guess, &path, &snapshot, + MAX_REWRITE_TOKENS, + MAX_CONTEXT_TOKENS, ); - - let bytes_remaining = TOTAL_BYTE_LIMIT.saturating_sub(input_excerpt.len()); - let input_events = prompt_for_events(events.iter(), bytes_remaining); - - // Note that input_outline is not currently used in prompt generation and so - // is not counted towards TOTAL_BYTE_LIMIT. + let input_events = prompt_for_events(&events, MAX_EVENT_TOKENS); let input_outline = prompt_for_outline(&snapshot); anyhow::Ok(BackgroundValues { input_events, - input_excerpt, - excerpt_range, + input_excerpt: input_excerpt.prompt, + speculated_output: input_excerpt.speculated_output, + editable_range: input_excerpt.editable_range.to_offset(&snapshot), input_outline, }) } @@ -462,7 +422,7 @@ impl Zeta { .await?; log::debug!( - "Events:\n{}\nExcerpt:\n{}", + "Events:\n{}\nExcerpt:\n{:?}", values.input_events, values.input_excerpt ); @@ -470,6 +430,7 @@ impl Zeta { let body = PredictEditsBody { input_events: values.input_events.clone(), input_excerpt: values.input_excerpt.clone(), + speculated_output: Some(values.speculated_output), outline: Some(values.input_outline.clone()), can_collect_data, diagnostic_groups: diagnostic_groups.and_then(|diagnostic_groups| { @@ -485,14 +446,13 @@ impl Zeta { let response = perform_predict_edits(client, llm_token, is_staff, body).await?; - let output_excerpt = response.output_excerpt; - log::debug!("completion response: {}", output_excerpt); + log::debug!("completion response: {}", &response.output_excerpt); Self::process_completion_response( - output_excerpt, + response, buffer, &snapshot, - values.excerpt_range, + values.editable_range, cursor_offset, path, values.input_outline, @@ -508,6 +468,8 @@ impl Zeta { // Generates several example completions of various states to fill the Zeta completion modal #[cfg(any(test, feature = "test-support"))] pub fn fill_with_fake_completions(&mut self, cx: &mut Context) -> Task<()> { + use language::Point; + let test_buffer_text = indoc::indoc! {r#"a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -526,6 +488,7 @@ impl Zeta { &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("e7861db5-0cea-4761-b1c5-ad083ac53a80").unwrap(), output_excerpt: format!("{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line [here's an edit] @@ -542,6 +505,7 @@ and then another &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("077c556a-2c49-44e2-bbc6-dafc09032a5e").unwrap(), output_excerpt: format!(r#"{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -558,6 +522,7 @@ and then another &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("df8c7b23-3d1d-4f99-a306-1f6264a41277").unwrap(), output_excerpt: format!(r#"{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -575,6 +540,7 @@ and then another &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("c743958d-e4d8-44a8-aa5b-eb1e305c5f5c").unwrap(), output_excerpt: format!(r#"{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -592,6 +558,7 @@ and then another &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("ff5cd7ab-ad06-4808-986e-d3391e7b8355").unwrap(), output_excerpt: format!(r#"{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -608,6 +575,7 @@ and then another &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("83cafa55-cdba-4b27-8474-1865ea06be94").unwrap(), output_excerpt: format!(r#"{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -623,6 +591,7 @@ and then another &buffer, position, PredictEditsResponse { + request_id: Uuid::parse_str("d5bd3afd-8723-47c7-bd77-15a3a926867b").unwrap(), output_excerpt: format!(r#"{EDITABLE_REGION_START_MARKER} a longggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line And maybe a short line @@ -697,7 +666,7 @@ and then another loop { let request_builder = http_client::Request::builder().method(Method::POST).uri( http_client - .build_zed_llm_url("/predict_edits", &[])? + .build_zed_llm_url("/predict_edits/v2", &[])? .as_ref(), ); let request = request_builder @@ -734,10 +703,10 @@ and then another #[allow(clippy::too_many_arguments)] fn process_completion_response( - output_excerpt: String, + prediction_response: PredictEditsResponse, buffer: Entity, snapshot: &BufferSnapshot, - excerpt_range: Range, + editable_range: Range, cursor_offset: usize, path: Arc, input_outline: String, @@ -747,6 +716,8 @@ and then another cx: &AsyncApp, ) -> Task>> { let snapshot = snapshot.clone(); + let request_id = prediction_response.request_id; + let output_excerpt = prediction_response.output_excerpt; cx.spawn(|cx| async move { let output_excerpt: Arc = output_excerpt.into(); @@ -754,9 +725,9 @@ and then another .background_executor() .spawn({ let output_excerpt = output_excerpt.clone(); - let excerpt_range = excerpt_range.clone(); + let editable_range = editable_range.clone(); let snapshot = snapshot.clone(); - async move { Self::parse_edits(output_excerpt, excerpt_range, &snapshot) } + async move { Self::parse_edits(output_excerpt, editable_range, &snapshot) } }) .await? .into(); @@ -777,9 +748,9 @@ and then another let edit_preview = edit_preview.await; Ok(Some(InlineCompletion { - id: InlineCompletionId::new(), + id: InlineCompletionId(request_id), path, - excerpt_range, + excerpt_range: editable_range, cursor_offset, edits, edit_preview, @@ -796,7 +767,7 @@ and then another fn parse_edits( output_excerpt: Arc, - excerpt_range: Range, + editable_range: Range, snapshot: &BufferSnapshot, ) -> Result, String)>> { let content = output_excerpt.replace(CURSOR_MARKER, ""); @@ -840,13 +811,13 @@ and then another let new_text = &content[..codefence_end]; let old_text = snapshot - .text_for_range(excerpt_range.clone()) + .text_for_range(editable_range.clone()) .collect::(); Ok(Self::compute_edits( old_text, new_text, - excerpt_range.start, + editable_range.start, &snapshot, )) } @@ -1080,9 +1051,7 @@ fn prompt_for_outline(snapshot: &BufferSnapshot) -> String { .unwrap(); if let Some(outline) = snapshot.outline(None) { - let guess_size = outline.items.len() * 15; - input_outline.reserve(guess_size); - for item in outline.items.iter() { + for item in &outline.items { let spacing = " ".repeat(item.depth); writeln!(input_outline, "{}{}", spacing, item.text).unwrap(); } @@ -1093,181 +1062,20 @@ fn prompt_for_outline(snapshot: &BufferSnapshot) -> String { input_outline } -fn prompt_for_excerpt( - offset: usize, - excerpt_range: &Range, - mut len_guess: usize, - path: &str, - snapshot: &BufferSnapshot, -) -> String { - let point_range = excerpt_range.to_point(snapshot); - - // Include one line of extra context before and after editable range, if those lines are non-empty. - let extra_context_before_range = - if point_range.start.row > 0 && !snapshot.is_line_blank(point_range.start.row - 1) { - let range = - (Point::new(point_range.start.row - 1, 0)..point_range.start).to_offset(snapshot); - len_guess += range.end - range.start; - Some(range) - } else { - None - }; - let extra_context_after_range = if point_range.end.row < snapshot.max_point().row - && !snapshot.is_line_blank(point_range.end.row + 1) - { - let range = (point_range.end - ..Point::new( - point_range.end.row + 1, - snapshot.line_len(point_range.end.row + 1), - )) - .to_offset(snapshot); - len_guess += range.end - range.start; - Some(range) - } else { - None - }; - - let mut prompt_excerpt = String::with_capacity(len_guess); - writeln!(prompt_excerpt, "```{}", path).unwrap(); - - if excerpt_range.start == 0 { - writeln!(prompt_excerpt, "{START_OF_FILE_MARKER}").unwrap(); - } - - if let Some(extra_context_before_range) = extra_context_before_range { - for chunk in snapshot.text_for_range(extra_context_before_range) { - prompt_excerpt.push_str(chunk); - } - } - writeln!(prompt_excerpt, "{EDITABLE_REGION_START_MARKER}").unwrap(); - for chunk in snapshot.text_for_range(excerpt_range.start..offset) { - prompt_excerpt.push_str(chunk); - } - prompt_excerpt.push_str(CURSOR_MARKER); - for chunk in snapshot.text_for_range(offset..excerpt_range.end) { - prompt_excerpt.push_str(chunk); - } - write!(prompt_excerpt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); - - if let Some(extra_context_after_range) = extra_context_after_range { - for chunk in snapshot.text_for_range(extra_context_after_range) { - prompt_excerpt.push_str(chunk); - } - } - - write!(prompt_excerpt, "\n```").unwrap(); - debug_assert!( - prompt_excerpt.len() <= len_guess, - "Excerpt length {} exceeds estimated length {}", - prompt_excerpt.len(), - len_guess - ); - prompt_excerpt -} - -fn excerpt_range_for_position( - cursor_point: Point, - byte_limit: usize, - line_limit: u32, - path: &str, - snapshot: &BufferSnapshot, -) -> Result<(Range, usize)> { - let cursor_row = cursor_point.row; - let last_buffer_row = snapshot.max_point().row; - - // This is an overestimate because it includes parts of prompt_for_excerpt which are - // conditionally skipped. - let mut len_guess = 0; - len_guess += "```".len() + path.len() + 1; - len_guess += START_OF_FILE_MARKER.len() + 1; - len_guess += EDITABLE_REGION_START_MARKER.len() + 1; - len_guess += CURSOR_MARKER.len(); - len_guess += EDITABLE_REGION_END_MARKER.len() + 1; - len_guess += "```".len() + 1; - - len_guess += usize::try_from(snapshot.line_len(cursor_row) + 1).unwrap(); - - if len_guess > byte_limit { - return Err(anyhow!("Current line too long to send to model.")); - } - - let mut excerpt_start_row = cursor_row; - let mut excerpt_end_row = cursor_row; - let mut no_more_before = cursor_row == 0; - let mut no_more_after = cursor_row >= last_buffer_row; - let mut row_delta = 1; - loop { - if !no_more_before { - let row = cursor_point.row - row_delta; - let line_len: usize = usize::try_from(snapshot.line_len(row) + 1).unwrap(); - let mut new_len_guess = len_guess + line_len; - if row == 0 { - new_len_guess += START_OF_FILE_MARKER.len() + 1; - } - if new_len_guess <= byte_limit { - len_guess = new_len_guess; - excerpt_start_row = row; - if row == 0 { - no_more_before = true; - } - } else { - no_more_before = true; - } - } - if excerpt_end_row - excerpt_start_row >= line_limit { - break; - } - if !no_more_after { - let row = cursor_point.row + row_delta; - let line_len: usize = usize::try_from(snapshot.line_len(row) + 1).unwrap(); - let new_len_guess = len_guess + line_len; - if new_len_guess <= byte_limit { - len_guess = new_len_guess; - excerpt_end_row = row; - if row >= last_buffer_row { - no_more_after = true; - } - } else { - no_more_after = true; - } - } - if excerpt_end_row - excerpt_start_row >= line_limit { - break; - } - if no_more_before && no_more_after { +fn prompt_for_events(events: &VecDeque, mut remaining_tokens: usize) -> String { + let mut result = String::new(); + for event in events.iter().rev() { + let event_string = event.to_prompt(); + let event_tokens = tokens_for_bytes(event_string.len()); + if event_tokens > remaining_tokens { break; } - row_delta += 1; - } - - let excerpt_start = Point::new(excerpt_start_row, 0); - let excerpt_end = Point::new(excerpt_end_row, snapshot.line_len(excerpt_end_row)); - Ok(( - excerpt_start.to_offset(snapshot)..excerpt_end.to_offset(snapshot), - len_guess, - )) -} -fn prompt_for_events<'a>( - events: impl Iterator, - mut bytes_remaining: usize, -) -> String { - let mut result = String::new(); - for event in events { if !result.is_empty() { - result.push('\n'); - result.push('\n'); - } - let event_string = event.to_prompt(); - let len = event_string.len(); - if len > PER_EVENT_BYTE_LIMIT { - continue; + result.insert_str(0, "\n\n"); } - if len > bytes_remaining { - break; - } - bytes_remaining -= len; - result.push_str(&event_string); + result.insert_str(0, &event_string); + remaining_tokens -= event_tokens; } result } @@ -1744,12 +1552,20 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider } Some(inline_completion::InlineCompletion { + id: Some(completion.id.to_string().into()), edits: edits[edit_start_ix..edit_end_ix].to_vec(), edit_preview: Some(completion.edit_preview.clone()), }) } } +fn tokens_for_bytes(bytes: usize) -> usize { + /// Typical number of string bytes per token for the purposes of limiting model input. This is + /// intentionally low to err on the side of underestimating limits. + const BYTES_PER_TOKEN_GUESS: usize = 3; + bytes / BYTES_PER_TOKEN_GUESS +} + #[cfg(test)] mod tests { use client::test::FakeServer; @@ -1757,6 +1573,7 @@ mod tests { use gpui::TestAppContext; use http_client::FakeHttpClient; use indoc::indoc; + use language::Point; use language_models::RefreshLlmTokenListener; use rpc::proto; use settings::SettingsStore; @@ -1784,7 +1601,7 @@ mod tests { edit_preview, path: Path::new("").into(), snapshot: cx.read(|cx| buffer.read(cx).snapshot()), - id: InlineCompletionId::new(), + id: InlineCompletionId(Uuid::new_v4()), excerpt_range: 0..0, cursor_offset: 0, input_outline: "".into(), @@ -1903,6 +1720,8 @@ mod tests { .status(200) .body( serde_json::to_string(&PredictEditsResponse { + request_id: Uuid::parse_str("7e86480f-3536-4d2c-9334-8213e3445d45") + .unwrap(), output_excerpt: completion_response.to_string(), }) .unwrap() diff --git a/docs/src/extensions/icon-themes.md b/docs/src/extensions/icon-themes.md index c8321e19388360b495e4ac62fd3fe569f4f23a48..56d50213f5bc7fb5780e9627fe20bb26acb435d8 100644 --- a/docs/src/extensions/icon-themes.md +++ b/docs/src/extensions/icon-themes.md @@ -30,7 +30,7 @@ Here is an example of the structure of an icon theme: "collapsed": "./icons/folder.svg", "expanded": "./icons/folder-open.svg" }, - "chevon_icons": { + "chevron_icons": { "collapsed": "./icons/chevron-right.svg", "expanded": "./icons/chevron-down.svg" }, diff --git a/docs/src/languages/elm.md b/docs/src/languages/elm.md index f1f4047d9e6e6d8fa11edea8b246338670738107..e5355a3e807c20ef2e296a186224b0a2c3e8e0c8 100644 --- a/docs/src/languages/elm.md +++ b/docs/src/languages/elm.md @@ -7,7 +7,7 @@ Elm support is available through the [Elm extension](https://github.com/zed-exte ## Setup -Zed support for Elm requires installation of `elm`, `elm-format`, `elm-review` and `elm`. +Zed support for Elm requires installation of `elm`, `elm-format`, and `elm-review`. 1. [Install Elm](https://guide.elm-lang.org/install/elm.html) (or run `brew install elm` on macOS). 2. Install `elm-review` to support code linting: diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css index d1b8e9b92653e7ffc0dce7245b5ebd3da378ef17..b4ef43518c88f768124fd96f2df1299a93dd02dc 100644 --- a/docs/theme/css/general.css +++ b/docs/theme/css/general.css @@ -79,6 +79,12 @@ h6 code { display: none !important; } +h2 { + padding-bottom: 1rem; + border-bottom: 1px solid; + border-color: var(--border-light); +} + h2, h3 { margin-block-start: 1.5em; diff --git a/docs/theme/css/variables.css b/docs/theme/css/variables.css index 55ae4a427da269620cb9b15d10ff33d0f4ace958..bd3b42522e0f59a6c632839f86d24461b1e3274f 100644 --- a/docs/theme/css/variables.css +++ b/docs/theme/css/variables.css @@ -13,8 +13,9 @@ --menu-bar-height: 64px; --font: "IA Writer Quattro S", sans-serif; --title-font: "Lora", "Helvetica Neue", Helvetica, Arial, sans-serif; - --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - Liberation Mono, Courier New, monospace; + --mono-font: + 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 */; @@ -97,7 +98,7 @@ --title-color: hsl(220, 92%, 80%); --border: hsl(220, 13%, 20%); - --border-light: hsl(220, 13%, 90%); + --border-light: hsl(220, 13%, 15%); --border-hover: hsl(220, 13%, 40%); --media-bg: hsl(220, 13%, 8%); diff --git a/extensions/emmet/.gitignore b/extensions/emmet/.gitignore index 6aba30215ee94f1b5af1ca029da3056fe1062781..62c0add260e0ad28057d36f9575ef66e430a5a20 100644 --- a/extensions/emmet/.gitignore +++ b/extensions/emmet/.gitignore @@ -1,3 +1,3 @@ -target *.wasm grammars +target diff --git a/extensions/php/languages/php/brackets.scm b/extensions/php/languages/php/brackets.scm index e3f280b71f85147b50a9b0339f3e616b80d132a1..988602aa8d715a2a771b2298a022d661c7683465 100644 --- a/extensions/php/languages/php/brackets.scm +++ b/extensions/php/languages/php/brackets.scm @@ -1 +1,4 @@ ("{" @open "}" @close) +("(" @open ")" @close) +("[" @open "]" @close) +("\"" @open "\"" @close)