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:
parent
78fd5b5f02
commit
39ac6e4a75
9 changed files with 405 additions and 70 deletions
|
@ -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 {}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue