Vim: enable sending multiple keystrokes from custom keybinding (#7965)

Release Notes:

- Added `workspace::SendKeystrokes` to enable mapping from one key to a
sequence of others
([#7033](https://github.com/zed-industries/zed/issues/7033)).

Improves #7033. Big thank you to @ConradIrwin who did most of the heavy
lifting on this one.

This PR allows the user to send multiple keystrokes via custom
keybinding. For example, the following keybinding would go down four
lines and then right four characters.

```json
[
  {
    "context": "Editor && VimControl && !VimWaiting && !menu",
    "bindings": {
      "g z": [
        "workspace::SendKeystrokes",
        "j j j j l l l l"
      ],
    }
  }
]
```

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
N 2024-02-20 17:01:45 -05:00 committed by GitHub
parent 8f5d7db875
commit 8a73bc4c7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 343 additions and 157 deletions

1
Cargo.lock generated
View File

@ -2183,6 +2183,7 @@ dependencies = [
"language",
"menu",
"picker",
"postage",
"project",
"release_channel",
"serde",

View File

@ -28,6 +28,7 @@ ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
postage.workspace = true
[dev-dependencies]
ctor.workspace = true

View File

@ -1,6 +1,7 @@
use std::{
cmp::{self, Reverse},
sync::Arc,
time::Duration,
};
use client::telemetry::Telemetry;
@ -9,11 +10,12 @@ use copilot::CommandPaletteFilter;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global,
ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView,
ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
};
use picker::{Picker, PickerDelegate};
use release_channel::{parse_zed_link, ReleaseChannel};
use postage::{sink::Sink, stream::Stream};
use release_channel::parse_zed_link;
use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing};
use util::ResultExt;
use workspace::{ModalView, Workspace};
@ -119,6 +121,10 @@ pub struct CommandPaletteDelegate {
selected_ix: usize,
telemetry: Arc<Telemetry>,
previous_focus_handle: FocusHandle,
updating_matches: Option<(
Task<()>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
)>,
}
struct Command {
@ -138,7 +144,7 @@ impl Clone for Command {
/// Hit count for each command in the palette.
/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
#[derive(Default)]
#[derive(Default, Clone)]
struct HitCounts(HashMap<String, usize>);
impl Global for HitCounts {}
@ -158,6 +164,66 @@ impl CommandPaletteDelegate {
selected_ix: 0,
telemetry,
previous_focus_handle,
updating_matches: None,
}
}
fn matches_updated(
&mut self,
query: String,
mut commands: Vec<Command>,
mut matches: Vec<StringMatch>,
cx: &mut ViewContext<Picker<Self>>,
) {
self.updating_matches.take();
let mut intercept_result =
if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
(interceptor.0)(&query, cx)
} else {
None
};
if parse_zed_link(&query).is_some() {
intercept_result = Some(CommandInterceptResult {
action: OpenZedUrl { url: query.clone() }.boxed_clone(),
string: query.clone(),
positions: vec![],
})
}
if let Some(CommandInterceptResult {
action,
string,
positions,
}) = intercept_result
{
if let Some(idx) = matches
.iter()
.position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
{
matches.remove(idx);
}
commands.push(Command {
name: string.clone(),
action,
});
matches.insert(
0,
StringMatch {
candidate_id: commands.len() - 1,
string,
positions,
score: 0.0,
},
)
}
self.commands = commands;
self.matches = matches;
if self.matches.is_empty() {
self.selected_ix = 0;
} else {
self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
}
}
}
@ -186,113 +252,99 @@ impl PickerDelegate for CommandPaletteDelegate {
query: String,
cx: &mut ViewContext<Picker<Self>>,
) -> gpui::Task<()> {
let mut commands = self.all_commands.clone();
cx.spawn(move |picker, mut cx| async move {
cx.read_global::<HitCounts, _>(|hit_counts, _| {
let (mut tx, mut rx) = postage::dispatch::channel(1);
let task = cx.background_executor().spawn({
let mut commands = self.all_commands.clone();
let hit_counts = cx.global::<HitCounts>().clone();
let executor = cx.background_executor().clone();
let query = query.clone();
async move {
commands.sort_by_key(|action| {
(
Reverse(hit_counts.0.get(&action.name).cloned()),
action.name.clone(),
)
});
})
.ok();
let candidates = commands
.iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate {
id: ix,
string: command.name.to_string(),
char_bag: command.name.chars().collect(),
})
.collect::<Vec<_>>();
let mut matches = if query.is_empty() {
candidates
.into_iter()
let candidates = commands
.iter()
.enumerate()
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
.map(|(ix, command)| StringMatchCandidate {
id: ix,
string: command.name.to_string(),
char_bag: command.name.chars().collect(),
})
.collect()
} else {
fuzzy::match_strings(
&candidates,
&query,
true,
10000,
&Default::default(),
cx.background_executor().clone(),
)
.await
.collect::<Vec<_>>();
let matches = if query.is_empty() {
candidates
.into_iter()
.enumerate()
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
let ret = fuzzy::match_strings(
&candidates,
&query,
true,
10000,
&Default::default(),
executor,
)
.await;
ret
};
tx.send((commands, matches)).await.log_err();
}
});
self.updating_matches = Some((task, rx.clone()));
cx.spawn(move |picker, mut cx| async move {
let Some((commands, matches)) = rx.recv().await else {
return;
};
let mut intercept_result = cx
.try_read_global(|interceptor: &CommandPaletteInterceptor, cx| {
(interceptor.0)(&query, cx)
})
.flatten();
let release_channel = cx
.update(|cx| ReleaseChannel::try_global(cx))
.ok()
.flatten();
if release_channel == Some(ReleaseChannel::Dev) {
if parse_zed_link(&query).is_some() {
intercept_result = Some(CommandInterceptResult {
action: OpenZedUrl { url: query.clone() }.boxed_clone(),
string: query.clone(),
positions: vec![],
})
}
}
if let Some(CommandInterceptResult {
action,
string,
positions,
}) = intercept_result
{
if let Some(idx) = matches
.iter()
.position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
{
matches.remove(idx);
}
commands.push(Command {
name: string.clone(),
action,
});
matches.insert(
0,
StringMatch {
candidate_id: commands.len() - 1,
string,
positions,
score: 0.0,
},
)
}
picker
.update(&mut cx, |picker, _| {
let delegate = &mut picker.delegate;
delegate.commands = commands;
delegate.matches = matches;
if delegate.matches.is_empty() {
delegate.selected_ix = 0;
} else {
delegate.selected_ix =
cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
}
.update(&mut cx, |picker, cx| {
picker
.delegate
.matches_updated(query, commands, matches, cx)
})
.log_err();
})
}
fn finalize_update_matches(
&mut self,
query: String,
duration: Duration,
cx: &mut ViewContext<Picker<Self>>,
) -> bool {
let Some((task, rx)) = self.updating_matches.take() else {
return true;
};
match cx
.background_executor()
.block_with_timeout(duration, rx.clone().recv())
{
Ok(Some((commands, matches))) => {
self.matches_updated(query, commands, matches, cx);
true
}
_ => {
self.updating_matches = Some((task, rx));
false
}
}
}
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
self.command_palette
.update(cx, |_, cx| cx.emit(DismissEvent))

View File

@ -147,7 +147,7 @@ impl EditorTestContext {
self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text));
let keystroke = Keystroke::parse(keystroke_text).unwrap();
self.cx.dispatch_keystroke(self.window, keystroke, false);
self.cx.dispatch_keystroke(self.window, keystroke);
keystroke_under_test_handle
}

View File

@ -39,7 +39,7 @@ use std::any::{Any, TypeId};
/// }
/// register_action!(Paste);
/// ```
pub trait Action: 'static {
pub trait Action: 'static + Send {
/// Clone the action into a new box
fn boxed_clone(&self) -> Box<dyn Action>;

View File

@ -335,7 +335,7 @@ impl TestAppContext {
.map(Keystroke::parse)
.map(Result::unwrap)
{
self.dispatch_keystroke(window, keystroke.into(), false);
self.dispatch_keystroke(window, keystroke.into());
}
self.background_executor.run_until_parked()
@ -347,21 +347,16 @@ impl TestAppContext {
/// This will also run the background executor until it's parked.
pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) {
self.dispatch_keystroke(window, keystroke.into(), false);
self.dispatch_keystroke(window, keystroke.into());
}
self.background_executor.run_until_parked()
}
/// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`)
pub fn dispatch_keystroke(
&mut self,
window: AnyWindowHandle,
keystroke: Keystroke,
is_held: bool,
) {
self.test_window(window)
.simulate_keystroke(keystroke, is_held)
pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) {
self.update_window(window, |_, cx| cx.dispatch_keystroke(keystroke))
.unwrap();
}
/// Returns the `TestWindow` backing the given handle.

View File

@ -491,8 +491,8 @@ mod test {
.update(cx, |test_view, cx| cx.focus(&test_view.focus_handle))
.unwrap();
cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap(), false);
cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap(), false);
cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap());
cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap());
window
.update(cx, |test_view, _| {

View File

@ -412,7 +412,7 @@ impl PlatformInputHandler {
.flatten()
}
pub(crate) fn flush_pending_input(&mut self, input: &str, cx: &mut WindowContext) {
pub(crate) fn dispatch_input(&mut self, input: &str, cx: &mut WindowContext) {
self.handler.replace_text_in_range(None, input, cx);
}
}

View File

@ -1,7 +1,7 @@
use crate::{
px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, KeyDownEvent, Keystroke,
Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
Point, Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions,
px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, Pixels, PlatformAtlas,
PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, Size,
TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions,
};
use collections::HashMap;
use parking_lot::Mutex;
@ -112,41 +112,6 @@ impl TestWindow {
self.0.lock().input_callback = Some(callback);
result
}
pub fn simulate_keystroke(&mut self, mut keystroke: Keystroke, is_held: bool) {
if keystroke.ime_key.is_none()
&& !keystroke.modifiers.command
&& !keystroke.modifiers.control
&& !keystroke.modifiers.function
{
keystroke.ime_key = Some(if keystroke.modifiers.shift {
keystroke.key.to_ascii_uppercase().clone()
} else {
keystroke.key.clone()
})
}
if self.simulate_input(PlatformInput::KeyDown(KeyDownEvent {
keystroke: keystroke.clone(),
is_held,
})) {
return;
}
let mut lock = self.0.lock();
let Some(mut input_handler) = lock.input_handler.take() else {
panic!(
"simulate_keystroke {:?} input event was not handled and there was no active input",
&keystroke
);
};
drop(lock);
if let Some(text) = keystroke.ime_key.as_ref() {
input_handler.replace_text_in_range(None, &text);
}
self.0.lock().input_handler = Some(input_handler);
}
}
impl PlatformWindow for TestWindow {

View File

@ -950,7 +950,7 @@ impl<'a> WindowContext<'a> {
/// Produces a new frame and assigns it to `rendered_frame`. To actually show
/// the contents of the new [Scene], use [present].
pub(crate) fn draw(&mut self) {
pub fn draw(&mut self) {
self.window.dirty.set(false);
self.window.drawing = true;
@ -1099,6 +1099,38 @@ impl<'a> WindowContext<'a> {
self.window.needs_present.set(false);
}
/// Dispatch a given keystroke as though the user had typed it.
/// You can create a keystroke with Keystroke::parse("").
pub fn dispatch_keystroke(&mut self, mut keystroke: Keystroke) -> bool {
if keystroke.ime_key.is_none()
&& !keystroke.modifiers.command
&& !keystroke.modifiers.control
&& !keystroke.modifiers.function
{
keystroke.ime_key = Some(if keystroke.modifiers.shift {
keystroke.key.to_uppercase().clone()
} else {
keystroke.key.clone()
})
}
if self.dispatch_event(PlatformInput::KeyDown(KeyDownEvent {
keystroke: keystroke.clone(),
is_held: false,
})) {
return true;
}
if let Some(input) = keystroke.ime_key {
if let Some(mut input_handler) = self.window.platform_window.take_input_handler() {
input_handler.dispatch_input(&input, self);
self.window.platform_window.set_input_handler(input_handler);
return true;
}
}
false
}
/// Dispatch a mouse or keyboard event on the window.
pub fn dispatch_event(&mut self, event: PlatformInput) -> bool {
self.window.last_input_timestamp.set(Instant::now());
@ -1423,7 +1455,7 @@ impl<'a> WindowContext<'a> {
if !input.is_empty() {
if let Some(mut input_handler) = self.window.platform_window.take_input_handler() {
input_handler.flush_pending_input(&input, self);
input_handler.dispatch_input(&input, self);
self.window.platform_window.set_input_handler(input_handler)
}
}

View File

@ -4,7 +4,7 @@ use gpui::{
FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task,
UniformListScrollHandle, View, ViewContext, WindowContext,
};
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
use workspace::ModalView;
@ -40,6 +40,19 @@ pub trait PickerDelegate: Sized + 'static {
fn placeholder_text(&self) -> Arc<str>;
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
// Delegates that support this method (e.g. the CommandPalette) can chose to block on any background
// work for up to `duration` to try and get a result synchronously.
// This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes
// mostly work when dismissing a palette.
fn finalize_update_matches(
&mut self,
_query: String,
_duration: Duration,
_cx: &mut ViewContext<Picker<Self>>,
) -> bool {
false
}
fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
@ -98,6 +111,9 @@ impl<D: PickerDelegate> Picker<D> {
is_modal: true,
};
this.update_matches("".to_string(), cx);
// give the delegate 4ms to renderthe first set of suggestions.
this.delegate
.finalize_update_matches("".to_string(), Duration::from_millis(4), cx);
this
}
@ -197,15 +213,24 @@ impl<D: PickerDelegate> Picker<D> {
}
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
if self.pending_update_matches.is_some() {
if self.pending_update_matches.is_some()
&& !self
.delegate
.finalize_update_matches(self.query(cx), Duration::from_millis(16), cx)
{
self.confirm_on_update = Some(false)
} else {
self.pending_update_matches.take();
self.delegate.confirm(false, cx);
}
}
fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
if self.pending_update_matches.is_some() {
if self.pending_update_matches.is_some()
&& !self
.delegate
.finalize_update_matches(self.query(cx), Duration::from_millis(16), cx)
{
self.confirm_on_update = Some(true)
} else {
self.delegate.confirm(true, cx);

View File

@ -884,3 +884,70 @@ async fn test_rename(cx: &mut gpui::TestAppContext) {
rename_request.next().await.unwrap();
cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal)
}
#[gpui::test]
async fn test_remap(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// test moving the cursor
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"g z",
workspace::SendKeystrokes("l l l l".to_string()),
None,
)])
});
cx.set_state("ˇ123456789", Mode::Normal);
cx.simulate_keystrokes(["g", "z"]);
cx.assert_state("1234ˇ56789", Mode::Normal);
// test switching modes
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"g y",
workspace::SendKeystrokes("i f o o escape l".to_string()),
None,
)])
});
cx.set_state("ˇ123456789", Mode::Normal);
cx.simulate_keystrokes(["g", "y"]);
cx.assert_state("fooˇ123456789", Mode::Normal);
// test recursion
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"g x",
workspace::SendKeystrokes("g z g y".to_string()),
None,
)])
});
cx.set_state("ˇ123456789", Mode::Normal);
cx.simulate_keystrokes(["g", "x"]);
cx.assert_state("1234fooˇ56789", Mode::Normal);
cx.executor().allow_parking();
// test command
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"g w",
workspace::SendKeystrokes(": j enter".to_string()),
None,
)])
});
cx.set_state("ˇ1234\n56789", Mode::Normal);
cx.simulate_keystrokes(["g", "w"]);
cx.assert_state("1234ˇ 56789", Mode::Normal);
// test leaving command
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"g u",
workspace::SendKeystrokes("g w g z".to_string()),
None,
)])
});
cx.set_state("ˇ1234\n56789", Mode::Normal);
cx.simulate_keystrokes(["g", "u"]);
cx.assert_state("1234 567ˇ89", Mode::Normal);
}

View File

@ -29,10 +29,10 @@ use gpui::{
actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView,
AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div,
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId,
ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext,
WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke,
LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point,
PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext,
VisualContext, WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
};
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
use itertools::Itertools;
@ -59,10 +59,11 @@ pub use status_bar::StatusItemView;
use std::{
any::TypeId,
borrow::Cow,
cell::RefCell,
cmp, env,
path::{Path, PathBuf},
sync::Weak,
sync::{atomic::AtomicUsize, Arc},
rc::Rc,
sync::{atomic::AtomicUsize, Arc, Weak},
time::Duration,
};
use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
@ -157,6 +158,9 @@ pub struct CloseAllItemsAndPanes {
pub save_intent: Option<SaveIntent>,
}
#[derive(Clone, Deserialize, PartialEq)]
pub struct SendKeystrokes(pub String);
impl_actions!(
workspace,
[
@ -168,6 +172,7 @@ impl_actions!(
Save,
SaveAll,
SwapPaneInDirection,
SendKeystrokes,
]
);
@ -499,6 +504,7 @@ pub struct Workspace {
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
database_id: WorkspaceId,
app_state: Arc<AppState>,
dispatching_keystrokes: Rc<RefCell<Vec<Keystroke>>>,
_subscriptions: Vec<Subscription>,
_apply_leader_updates: Task<Result<()>>,
_observe_current_user: Task<Result<()>>,
@ -754,6 +760,7 @@ impl Workspace {
project: project.clone(),
follower_states: Default::default(),
last_leaders_by_pane: Default::default(),
dispatching_keystrokes: Default::default(),
window_edited: false,
active_call,
database_id: workspace_id,
@ -1252,6 +1259,46 @@ impl Workspace {
.detach_and_log_err(cx);
}
fn send_keystrokes(&mut self, action: &SendKeystrokes, cx: &mut ViewContext<Self>) {
let mut keystrokes: Vec<Keystroke> = action
.0
.split(" ")
.flat_map(|k| Keystroke::parse(k).log_err())
.collect();
keystrokes.reverse();
self.dispatching_keystrokes
.borrow_mut()
.append(&mut keystrokes);
let keystrokes = self.dispatching_keystrokes.clone();
cx.window_context()
.spawn(|mut cx| async move {
// limit to 100 keystrokes to avoid infinite recursion.
for _ in 0..100 {
let Some(keystroke) = keystrokes.borrow_mut().pop() else {
return Ok(());
};
cx.update(|cx| {
let focused = cx.focused();
cx.dispatch_keystroke(keystroke.clone());
if cx.focused() != focused {
// dispatch_keystroke may cause the focus to change.
// draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
// And we need that to happen before the next keystroke to keep vim mode happy...
// (Note that the tests always do this implicitly, so you must manually test with something like:
// "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
// )
cx.draw();
}
})?;
}
keystrokes.borrow_mut().clear();
Err(anyhow!("over 100 keystrokes passed to send_keystrokes"))
})
.detach_and_log_err(cx);
}
fn save_all_internal(
&mut self,
mut save_intent: SaveIntent,
@ -3461,6 +3508,7 @@ impl Workspace {
.on_action(cx.listener(Self::close_inactive_items_and_panes))
.on_action(cx.listener(Self::close_all_items_and_panes))
.on_action(cx.listener(Self::save_all))
.on_action(cx.listener(Self::send_keystrokes))
.on_action(cx.listener(Self::add_folder_to_project))
.on_action(cx.listener(Self::follow_next_collaborator))
.on_action(cx.listener(|workspace, _: &Unfollow, cx| {