Introducing multibuffers (#14668)

Conrad Irwin and Marshall created

Co-Authored-By: Marshall <marshall@zed.dev>

Release Notes:

- Added a hint the first few times you open a multibuffer to explain
what is going on.

Co-authored-by: Marshall <marshall@zed.dev>

Change summary

crates/welcome/src/multibuffer_hint.rs | 142 ++++++++++++++++++++++++++++
crates/welcome/src/welcome.rs          |  12 +
crates/zed/src/zed.rs                  |   4 
docs/src/SUMMARY.md                    |   1 
docs/src/multibuffers.md               |  25 ++++
5 files changed, 180 insertions(+), 4 deletions(-)

Detailed changes

crates/welcome/src/multibuffer_hint.rs πŸ”—

@@ -0,0 +1,142 @@
+use std::collections::HashSet;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::OnceLock;
+
+use db::kvp::KEY_VALUE_STORE;
+use gpui::{AppContext, Empty, EntityId, EventEmitter};
+use ui::{prelude::*, ButtonLike, IconButtonShape, Tooltip};
+use workspace::item::ItemHandle;
+use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
+
+pub struct MultibufferHint {
+    shown_on: HashSet<EntityId>,
+    active_item: Option<Box<dyn ItemHandle>>,
+}
+
+const NUMBER_OF_HINTS: usize = 10;
+
+const SHOWN_COUNT_KEY: &str = "MULTIBUFFER_HINT_SHOWN_COUNT";
+
+impl MultibufferHint {
+    pub fn new() -> Self {
+        Self {
+            shown_on: Default::default(),
+            active_item: None,
+        }
+    }
+}
+
+impl MultibufferHint {
+    fn counter() -> &'static AtomicUsize {
+        static SHOWN_COUNT: OnceLock<AtomicUsize> = OnceLock::new();
+        SHOWN_COUNT.get_or_init(|| {
+            let value: usize = KEY_VALUE_STORE
+                .read_kvp(SHOWN_COUNT_KEY)
+                .ok()
+                .flatten()
+                .and_then(|v| v.parse().ok())
+                .unwrap_or(0);
+
+            AtomicUsize::new(value)
+        })
+    }
+
+    fn shown_count() -> usize {
+        Self::counter().load(Ordering::Relaxed)
+    }
+
+    fn increment_count(cx: &mut AppContext) {
+        Self::set_count(Self::shown_count() + 1, cx)
+    }
+
+    pub(crate) fn set_count(count: usize, cx: &mut AppContext) {
+        Self::counter().store(count, Ordering::Relaxed);
+
+        db::write_and_log(cx, move || {
+            KEY_VALUE_STORE.write_kvp(SHOWN_COUNT_KEY.to_string(), format!("{}", count))
+        });
+    }
+
+    fn dismiss(&mut self, cx: &mut AppContext) {
+        Self::set_count(NUMBER_OF_HINTS, cx)
+    }
+}
+
+impl EventEmitter<ToolbarItemEvent> for MultibufferHint {}
+
+impl ToolbarItemView for MultibufferHint {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        cx: &mut ViewContext<Self>,
+    ) -> ToolbarItemLocation {
+        if Self::shown_count() > NUMBER_OF_HINTS {
+            return ToolbarItemLocation::Hidden;
+        }
+
+        let Some(active_pane_item) = active_pane_item else {
+            return ToolbarItemLocation::Hidden;
+        };
+
+        if active_pane_item.is_singleton(cx) {
+            return ToolbarItemLocation::Hidden;
+        }
+
+        if self.shown_on.insert(active_pane_item.item_id()) {
+            Self::increment_count(cx)
+        }
+
+        self.active_item = Some(active_pane_item.boxed_clone());
+        ToolbarItemLocation::Secondary
+    }
+}
+
+impl Render for MultibufferHint {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let Some(active_item) = self.active_item.as_ref() else {
+            return Empty.into_any_element();
+        };
+
+        if active_item.breadcrumbs(cx.theme(), cx).is_none() {
+            return Empty.into_any_element();
+        }
+
+        h_flex()
+            .px_2()
+            .justify_between()
+            .bg(cx.theme().status().info_background)
+            .rounded_md()
+            .child(
+                h_flex()
+                    .gap_2()
+                    .child(Label::new("You can edit results inline in multibuffers!"))
+                    .child(
+                        ButtonLike::new("open_docs")
+                            .style(ButtonStyle::Transparent)
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(Label::new("Read more…"))
+                                    .child(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
+                            )
+                            .on_click(move |_event, cx| {
+                                cx.open_url("https://zed.dev/docs/multibuffers")
+                            }),
+                    ),
+            )
+            .child(
+                IconButton::new("dismiss", IconName::Close)
+                    .style(ButtonStyle::Transparent)
+                    .shape(IconButtonShape::Square)
+                    .icon_size(IconSize::Small)
+                    .on_click(cx.listener(|this, _event, cx| {
+                        this.dismiss(cx);
+                        cx.emit(ToolbarItemEvent::ChangeLocation(
+                            ToolbarItemLocation::Hidden,
+                        ))
+                    }))
+                    .tooltip(move |cx| Tooltip::text("Dismiss this hint", cx)),
+            )
+            .into_any_element()
+    }
+}

crates/welcome/src/welcome.rs πŸ”—

@@ -1,12 +1,13 @@
 mod base_keymap_picker;
 mod base_keymap_setting;
+mod multibuffer_hint;
 
 use client::{telemetry::Telemetry, TelemetrySettings};
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
-    svg, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
-    ParentElement, Render, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView,
-    WindowContext,
+    actions, svg, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView,
+    InteractiveElement, ParentElement, Render, Styled, Subscription, Task, View, ViewContext,
+    VisualContext, WeakView, WindowContext,
 };
 use settings::{Settings, SettingsStore};
 use std::sync::Arc;
@@ -19,6 +20,9 @@ use workspace::{
 };
 
 pub use base_keymap_setting::BaseKeymap;
+pub use multibuffer_hint::*;
+
+actions!(welcome, [ResetHints]);
 
 pub const FIRST_OPEN: &str = "first_open";
 
@@ -30,6 +34,8 @@ pub fn init(cx: &mut AppContext) {
             let welcome_page = WelcomePage::new(workspace, cx);
             workspace.add_item_to_active_pane(Box::new(welcome_page), None, cx)
         });
+        workspace
+            .register_action(|_workspace, _: &ResetHints, cx| MultibufferHint::set_count(0, cx));
     })
     .detach();
 

crates/zed/src/zed.rs πŸ”—

@@ -41,7 +41,7 @@ use terminal_view::terminal_panel::{self, TerminalPanel};
 use util::{asset_str, ResultExt};
 use uuid::Uuid;
 use vim::VimModeSetting;
-use welcome::BaseKeymap;
+use welcome::{BaseKeymap, MultibufferHint};
 use workspace::{
     create_and_open_local_file, notifications::simple_message_notification::MessageNotification,
     open_new, AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings,
@@ -495,6 +495,8 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
 fn initialize_pane(workspace: &mut Workspace, pane: &View<Pane>, cx: &mut ViewContext<Workspace>) {
     pane.update(cx, |pane, cx| {
         pane.toolbar().update(cx, |toolbar, cx| {
+            let multibuffer_hint = cx.new_view(|_| MultibufferHint::new());
+            toolbar.add_item(multibuffer_hint, cx);
             let breadcrumbs = cx.new_view(|_| Breadcrumbs::new());
             toolbar.add_item(breadcrumbs, cx);
             let buffer_search_bar = cx.new_view(search::BufferSearchBar::new);

docs/src/SUMMARY.md πŸ”—

@@ -17,6 +17,7 @@
 
 # Using Zed
 
+- [Multibuffers](./multibuffers.md)
 - [Assistant Panel](./assistant-panel.md)
 - [Channels](./channels.md)
 - [Collaboration](./collaboration.md)

docs/src/multibuffers.md πŸ”—

@@ -0,0 +1,25 @@
+# Multibuffers
+
+One of the superpowers Zed gives you is the ability to edit multiple files simultaneously. When combined with multiple cursors, this makes wide-ranging refactors significantly faster.
+
+## Editing in a multibuffer
+
+Editing a multibuffer is the same as editing a normal file. Changes you make will be reflected in the open copies of that file in the rest of the editor, and you can save all files with `editor: Save` (bound to `cmd-s` on macOS, `ctrl-s` on Windows/Linux, or `:w` in Vim mode).
+
+When in a multibuffer, it is often useful to use multiple cursors to edit every file simultaneously. If you want to edit a few instances, you can select them with the mouse (`option-click` on macOS, `alt-click` on Window/Linux) or the keyboard. `cmd-d` on macOS, `ctrl-d` on Windows/Linux, or `gl` in Vim mode will select the next match of the word under the cursor.
+
+When you want to edit all matches you can select them by running the `editor: Select All Matches` command (`cmd-shift-l` on macOS, `ctrl-shift-l` on Windows/Linux, or `g a` in Vim mode).
+
+## Project search
+
+To start a search run the `pane: Toggle Search` command (`cmd-shift-f` on macOS, `ctrl-shift-f` on Windows/Linux, or `g/` in Vim mode). After the search has completed, the results will be shown in a new multibuffer. There will be one excerpt for each matching line across the whole project.
+
+## Diagnostics
+
+If you have a language server installed, the diagnostics pane can show you all errors across your project. You can open it by clicking on the icon in the status bar, or running the `diagnostcs: Deploy` command` ('cmd-shift-m` on macOS, `ctrl-shift-m` on Windows/Linux, or `:clist` in Vim mode).
+
+## Find References
+
+If you have a language server installed, you can find all references to the symbol under the cursor with the `editor: Find References` command (`cmd-click` on macOS, `ctrl-click` on Windows/Linux, or `g A` in Vim mode.
+
+Depending on your language server, commands like `editor: Go To Definition` and `editor: Go To Type Definition` will also open a multibuffer if there are multiple possible definitions.