assistant2: Navigate context strip with keyboard (#23128)

Context pills are now focusable and intractable via the keyboard.

- <kbd>←</kbd> and <kbd>→</kbd> move the focus to the previous or next
item (wrapping if necessary)
- <kbd>↓</kbd> and <kbd>↑</kbd> move the focus vertically
- If the cursor is in the first/last row of the assistant/inline editor,
they will move the focus to the strip
- Inside the strip, they will move the focus to the pill horizontally
overlapping the most
- If already in the first/last row of the strip, they will move to the
first/last pill (like in editors)
- If the first/last pill is focused, they will move the focus back to
the editor
- <kbd>⌫</kbd>  removes the focused pill (unless it's the suggested one)
- <kbd>⏎</kbd> accepts the suggested pill if focused
  


https://github.com/user-attachments/assets/040bc71c-a3ae-4961-9886-2d5c3d290a73



Release Notes:

- N/A
This commit is contained in:
Agus Zubiaga 2025-01-14 13:45:11 -03:00 committed by GitHub
parent 78fd5b5f02
commit 39ac6e4a75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 405 additions and 70 deletions

View file

@ -4,7 +4,8 @@ use collections::HashSet;
use editor::Editor;
use file_icons::FileIcons;
use gpui::{
DismissEvent, EventEmitter, FocusHandle, Model, Subscription, View, WeakModel, WeakView,
AppContext, Bounds, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
Subscription, View, WeakModel, WeakView,
};
use itertools::Itertools;
use language::Buffer;
@ -17,7 +18,10 @@ use crate::context_store::ContextStore;
use crate::thread::Thread;
use crate::thread_store::ThreadStore;
use crate::ui::ContextPill;
use crate::{AssistantPanel, RemoveAllContext, ToggleContextPicker};
use crate::{
AcceptSuggestedContext, AssistantPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
};
pub struct ContextStrip {
context_store: Model<ContextStore>,
@ -26,7 +30,9 @@ pub struct ContextStrip {
focus_handle: FocusHandle,
suggest_context_kind: SuggestContextKind,
workspace: WeakView<Workspace>,
_context_picker_subscription: Subscription,
_subscriptions: Vec<Subscription>,
focused_index: Option<usize>,
children_bounds: Option<Vec<Bounds<Pixels>>>,
}
impl ContextStrip {
@ -34,7 +40,6 @@ impl ContextStrip {
context_store: Model<ContextStore>,
workspace: WeakView<Workspace>,
thread_store: Option<WeakModel<ThreadStore>>,
focus_handle: FocusHandle,
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
suggest_context_kind: SuggestContextKind,
cx: &mut ViewContext<Self>,
@ -49,8 +54,13 @@ impl ContextStrip {
)
});
let context_picker_subscription =
cx.subscribe(&context_picker, Self::handle_context_picker_event);
let focus_handle = cx.focus_handle();
let subscriptions = vec![
cx.subscribe(&context_picker, Self::handle_context_picker_event),
cx.on_focus(&focus_handle, Self::handle_focus),
cx.on_blur(&focus_handle, Self::handle_blur),
];
Self {
context_store: context_store.clone(),
@ -59,7 +69,9 @@ impl ContextStrip {
focus_handle,
suggest_context_kind,
workspace,
_context_picker_subscription: context_picker_subscription,
_subscriptions: subscriptions,
focused_index: None,
children_bounds: None,
}
}
@ -137,6 +149,199 @@ impl ContextStrip {
) {
cx.emit(ContextStripEvent::PickerDismissed);
}
fn handle_focus(&mut self, cx: &mut ViewContext<Self>) {
self.focused_index = self.last_pill_index();
cx.notify();
}
fn handle_blur(&mut self, cx: &mut ViewContext<Self>) {
self.focused_index = None;
cx.notify();
}
fn focus_left(&mut self, _: &FocusLeft, cx: &mut ViewContext<Self>) {
self.focused_index = match self.focused_index {
Some(index) if index > 0 => Some(index - 1),
_ => self.last_pill_index(),
};
cx.notify();
}
fn focus_right(&mut self, _: &FocusRight, cx: &mut ViewContext<Self>) {
let Some(last_index) = self.last_pill_index() else {
return;
};
self.focused_index = match self.focused_index {
Some(index) if index < last_index => Some(index + 1),
_ => Some(0),
};
cx.notify();
}
fn focus_up(&mut self, _: &FocusUp, cx: &mut ViewContext<Self>) {
let Some(focused_index) = self.focused_index else {
return;
};
if focused_index == 0 {
return cx.emit(ContextStripEvent::BlurredUp);
}
let Some((focused, pills)) = self.focused_bounds(focused_index) else {
return;
};
let iter = pills[..focused_index].iter().enumerate().rev();
self.focused_index = Self::find_best_horizontal_match(focused, iter).or(Some(0));
cx.notify();
}
fn focus_down(&mut self, _: &FocusDown, cx: &mut ViewContext<Self>) {
let Some(focused_index) = self.focused_index else {
return;
};
let last_index = self.last_pill_index();
if self.focused_index == last_index {
return cx.emit(ContextStripEvent::BlurredDown);
}
let Some((focused, pills)) = self.focused_bounds(focused_index) else {
return;
};
let iter = pills.iter().enumerate().skip(focused_index + 1);
self.focused_index = Self::find_best_horizontal_match(focused, iter).or(last_index);
cx.notify();
}
fn focused_bounds(&self, focused: usize) -> Option<(&Bounds<Pixels>, &[Bounds<Pixels>])> {
let pill_bounds = self.pill_bounds()?;
let focused = pill_bounds.get(focused)?;
Some((focused, pill_bounds))
}
fn pill_bounds(&self) -> Option<&[Bounds<Pixels>]> {
let bounds = self.children_bounds.as_ref()?;
let eraser = if bounds.len() < 3 { 0 } else { 1 };
let pills = &bounds[1..bounds.len() - eraser];
if pills.is_empty() {
None
} else {
Some(pills)
}
}
fn last_pill_index(&self) -> Option<usize> {
Some(self.pill_bounds()?.len() - 1)
}
fn find_best_horizontal_match<'a>(
focused: &'a Bounds<Pixels>,
iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
) -> Option<usize> {
let mut best = None;
let focused_left = focused.left();
let focused_right = focused.right();
for (index, probe) in iter {
if probe.origin.y == focused.origin.y {
continue;
}
let overlap = probe.right().min(focused_right) - probe.left().max(focused_left);
best = match best {
Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => {
break;
}
Some(_) | None => Some((index, overlap, probe.origin.y)),
};
}
best.map(|(index, _, _)| index)
}
fn remove_focused_context(&mut self, _: &RemoveFocusedContext, cx: &mut ViewContext<Self>) {
if let Some(index) = self.focused_index {
let mut is_empty = false;
self.context_store.update(cx, |this, _cx| {
if let Some(item) = this.context().get(index) {
this.remove_context(item.id());
}
is_empty = this.context().is_empty();
});
if is_empty {
cx.emit(ContextStripEvent::BlurredEmpty);
} else {
self.focused_index = Some(index.saturating_sub(1));
cx.notify();
}
}
}
fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
// We only suggest one item after the actual context
self.focused_index == Some(context.len())
}
fn accept_suggested_context(&mut self, _: &AcceptSuggestedContext, cx: &mut ViewContext<Self>) {
if let Some(suggested) = self.suggested_context(cx) {
let context_store = self.context_store.read(cx);
if self.is_suggested_focused(context_store.context()) {
self.add_suggested_context(&suggested, cx);
}
}
}
fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut ViewContext<Self>) {
let task = self.context_store.update(cx, |context_store, cx| {
context_store.accept_suggested_context(&suggested, cx)
});
let workspace = self.workspace.clone();
cx.spawn(|this, mut cx| async move {
match task.await {
Ok(()) => {
if let Some(this) = this.upgrade() {
this.update(&mut cx, |_, cx| cx.notify())?;
}
}
Err(err) => {
let Some(workspace) = workspace.upgrade() else {
return anyhow::Ok(());
};
workspace.update(&mut cx, |workspace, cx| {
workspace.show_error(&err, cx);
})?;
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
cx.notify();
}
}
impl FocusableView for ContextStrip {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ContextStrip {
@ -164,6 +369,23 @@ impl Render for ContextStrip {
h_flex()
.flex_wrap()
.gap_1()
.track_focus(&focus_handle)
.key_context("ContextStrip")
.on_action(cx.listener(Self::focus_up))
.on_action(cx.listener(Self::focus_right))
.on_action(cx.listener(Self::focus_down))
.on_action(cx.listener(Self::focus_left))
.on_action(cx.listener(Self::remove_focused_context))
.on_action(cx.listener(Self::accept_suggested_context))
.on_children_prepainted({
let view = cx.view().downgrade();
move |children_bounds, cx| {
view.update(cx, |this, _| {
this.children_bounds = Some(children_bounds);
})
.ok();
}
})
.child(
PopoverMenu::new("context-picker")
.menu(move |cx| {
@ -217,10 +439,11 @@ impl Render for ContextStrip {
)
}
})
.children(context.iter().map(|context| {
.children(context.iter().enumerate().map(|(i, context)| {
ContextPill::new_added(
context.clone(),
dupe_names.contains(&context.name),
self.focused_index == Some(i),
Some({
let id = context.id;
let context_store = self.context_store.clone();
@ -232,43 +455,23 @@ impl Render for ContextStrip {
}))
}),
)
.on_click(Rc::new(cx.listener(move |this, _, cx| {
this.focused_index = Some(i);
cx.notify();
})))
}))
.when_some(suggested_context, |el, suggested| {
el.child(ContextPill::new_suggested(
suggested.name().clone(),
suggested.icon_path(),
suggested.kind(),
{
let context_store = self.context_store.clone();
Rc::new(cx.listener(move |this, _event, cx| {
let task = context_store.update(cx, |context_store, cx| {
context_store.accept_suggested_context(&suggested, cx)
});
let workspace = this.workspace.clone();
cx.spawn(|this, mut cx| async move {
match task.await {
Ok(()) => {
if let Some(this) = this.upgrade() {
this.update(&mut cx, |_, cx| cx.notify())?;
}
}
Err(err) => {
let Some(workspace) = workspace.upgrade() else {
return anyhow::Ok(());
};
workspace.update(&mut cx, |workspace, cx| {
workspace.show_error(&err, cx);
})?;
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}))
},
))
el.child(
ContextPill::new_suggested(
suggested.name().clone(),
suggested.icon_path(),
suggested.kind(),
self.is_suggested_focused(&context),
)
.on_click(Rc::new(cx.listener(move |this, _event, cx| {
this.add_suggested_context(&suggested, cx);
}))),
)
})
.when(!context.is_empty(), {
move |parent| {
@ -300,6 +503,9 @@ impl Render for ContextStrip {
pub enum ContextStripEvent {
PickerDismissed,
BlurredEmpty,
BlurredDown,
BlurredUp,
}
impl EventEmitter<ContextStripEvent> for ContextStrip {}