assistant2: Rework @mentions (#26983)

https://github.com/user-attachments/assets/167f753f-2775-4d31-bfef-55565e61e4bc

Release Notes:

- N/A
This commit is contained in:
Bennet Bo Fenner 2025-03-24 19:32:52 +01:00 committed by GitHub
parent 4a5f89aded
commit 699369995b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1637 additions and 485 deletions

View file

@ -1,19 +1,28 @@
mod completion_provider;
mod fetch_context_picker; mod fetch_context_picker;
mod file_context_picker; mod file_context_picker;
mod thread_context_picker; mod thread_context_picker;
use std::ops::Range;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use editor::Editor; use editor::display_map::{Crease, FoldId};
use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
use file_context_picker::render_file_context_entry; use file_context_picker::render_file_context_entry;
use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; use gpui::{
App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
};
use multi_buffer::MultiBufferRow;
use project::ProjectPath; use project::ProjectPath;
use thread_context_picker::{render_thread_context_entry, ThreadContextEntry}; use thread_context_picker::{render_thread_context_entry, ThreadContextEntry};
use ui::{prelude::*, ContextMenu, ContextMenuEntry, ContextMenuItem}; use ui::{
prelude::*, ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor,
};
use workspace::{notifications::NotifyResultExt, Workspace}; use workspace::{notifications::NotifyResultExt, Workspace};
pub use crate::context_picker::completion_provider::ContextPickerCompletionProvider;
use crate::context_picker::fetch_context_picker::FetchContextPicker; use crate::context_picker::fetch_context_picker::FetchContextPicker;
use crate::context_picker::file_context_picker::FileContextPicker; use crate::context_picker::file_context_picker::FileContextPicker;
use crate::context_picker::thread_context_picker::ThreadContextPicker; use crate::context_picker::thread_context_picker::ThreadContextPicker;
@ -34,7 +43,28 @@ enum ContextPickerMode {
Thread, Thread,
} }
impl TryFrom<&str> for ContextPickerMode {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"file" => Ok(Self::File),
"fetch" => Ok(Self::Fetch),
"thread" => Ok(Self::Thread),
_ => Err(format!("Invalid context picker mode: {}", value)),
}
}
}
impl ContextPickerMode { impl ContextPickerMode {
pub fn mention_prefix(&self) -> &'static str {
match self {
Self::File => "file",
Self::Fetch => "fetch",
Self::Thread => "thread",
}
}
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
Self::File => "File/Directory", Self::File => "File/Directory",
@ -63,7 +93,6 @@ enum ContextPickerState {
pub(super) struct ContextPicker { pub(super) struct ContextPicker {
mode: ContextPickerState, mode: ContextPickerState,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
context_store: WeakEntity<ContextStore>, context_store: WeakEntity<ContextStore>,
thread_store: Option<WeakEntity<ThreadStore>>, thread_store: Option<WeakEntity<ThreadStore>>,
confirm_behavior: ConfirmBehavior, confirm_behavior: ConfirmBehavior,
@ -74,7 +103,6 @@ impl ContextPicker {
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
thread_store: Option<WeakEntity<ThreadStore>>, thread_store: Option<WeakEntity<ThreadStore>>,
context_store: WeakEntity<ContextStore>, context_store: WeakEntity<ContextStore>,
editor: WeakEntity<Editor>,
confirm_behavior: ConfirmBehavior, confirm_behavior: ConfirmBehavior,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -88,7 +116,6 @@ impl ContextPicker {
workspace, workspace,
context_store, context_store,
thread_store, thread_store,
editor,
confirm_behavior, confirm_behavior,
} }
} }
@ -109,10 +136,7 @@ impl ContextPicker {
.enumerate() .enumerate()
.map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry)); .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
let mut modes = vec![ContextPickerMode::File, ContextPickerMode::Fetch]; let modes = supported_context_picker_modes(&self.thread_store);
if self.allow_threads() {
modes.push(ContextPickerMode::Thread);
}
let menu = menu let menu = menu
.when(has_recent, |menu| { .when(has_recent, |menu| {
@ -174,7 +198,6 @@ impl ContextPicker {
FileContextPicker::new( FileContextPicker::new(
context_picker.clone(), context_picker.clone(),
self.workspace.clone(), self.workspace.clone(),
self.editor.clone(),
self.context_store.clone(), self.context_store.clone(),
self.confirm_behavior, self.confirm_behavior,
window, window,
@ -278,7 +301,7 @@ impl ContextPicker {
}; };
let task = context_store.update(cx, |context_store, cx| { let task = context_store.update(cx, |context_store, cx| {
context_store.add_file_from_path(project_path.clone(), cx) context_store.add_file_from_path(project_path.clone(), true, cx)
}); });
cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx)) cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
@ -308,7 +331,7 @@ impl ContextPicker {
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let thread = open_thread_task.await?; let thread = open_thread_task.await?;
context_store.update(cx, |context_store, cx| { context_store.update(cx, |context_store, cx| {
context_store.add_thread(thread, cx); context_store.add_thread(thread, true, cx);
})?; })?;
this.update(cx, |_this, cx| cx.notify()) this.update(cx, |_this, cx| cx.notify())
@ -328,7 +351,7 @@ impl ContextPicker {
let mut current_files = context_store.file_paths(cx); let mut current_files = context_store.file_paths(cx);
if let Some(active_path) = Self::active_singleton_buffer_path(&workspace, cx) { if let Some(active_path) = active_singleton_buffer_path(&workspace, cx) {
current_files.insert(active_path); current_files.insert(active_path);
} }
@ -384,16 +407,6 @@ impl ContextPicker {
recent recent
} }
fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
let active_item = workspace.active_item(cx)?;
let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
let buffer = editor.buffer().read(cx).as_singleton()?;
let path = buffer.read(cx).file()?.path().to_path_buf();
Some(path)
}
} }
impl EventEmitter<DismissEvent> for ContextPicker {} impl EventEmitter<DismissEvent> for ContextPicker {}
@ -429,3 +442,212 @@ enum RecentEntry {
}, },
Thread(ThreadContextEntry), Thread(ThreadContextEntry),
} }
fn supported_context_picker_modes(
thread_store: &Option<WeakEntity<ThreadStore>>,
) -> Vec<ContextPickerMode> {
let mut modes = vec![ContextPickerMode::File, ContextPickerMode::Fetch];
if thread_store.is_some() {
modes.push(ContextPickerMode::Thread);
}
modes
}
fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
let active_item = workspace.active_item(cx)?;
let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
let buffer = editor.buffer().read(cx).as_singleton()?;
let path = buffer.read(cx).file()?.path().to_path_buf();
Some(path)
}
fn recent_context_picker_entries(
context_store: Entity<ContextStore>,
thread_store: Option<WeakEntity<ThreadStore>>,
workspace: Entity<Workspace>,
cx: &App,
) -> Vec<RecentEntry> {
let mut recent = Vec::with_capacity(6);
let mut current_files = context_store.read(cx).file_paths(cx);
let workspace = workspace.read(cx);
if let Some(active_path) = active_singleton_buffer_path(workspace, cx) {
current_files.insert(active_path);
}
let project = workspace.project().read(cx);
recent.extend(
workspace
.recent_navigation_history_iter(cx)
.filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
.take(4)
.filter_map(|(project_path, _)| {
project
.worktree_for_id(project_path.worktree_id, cx)
.map(|worktree| RecentEntry::File {
project_path,
path_prefix: worktree.read(cx).root_name().into(),
})
}),
);
let mut current_threads = context_store.read(cx).thread_ids();
if let Some(active_thread) = workspace
.panel::<AssistantPanel>(cx)
.map(|panel| panel.read(cx).active_thread(cx))
{
current_threads.insert(active_thread.read(cx).id().clone());
}
if let Some(thread_store) = thread_store.and_then(|thread_store| thread_store.upgrade()) {
recent.extend(
thread_store
.read(cx)
.threads()
.into_iter()
.filter(|thread| !current_threads.contains(&thread.id))
.take(2)
.map(|thread| {
RecentEntry::Thread(ThreadContextEntry {
id: thread.id,
summary: thread.summary,
})
}),
);
}
recent
}
pub(crate) fn insert_crease_for_mention(
excerpt_id: ExcerptId,
crease_start: text::Anchor,
content_len: usize,
crease_label: SharedString,
crease_icon_path: SharedString,
editor_entity: Entity<Editor>,
window: &mut Window,
cx: &mut App,
) {
editor_entity.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else {
return;
};
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder {
render: render_fold_icon_button(
crease_icon_path,
crease_label,
editor_entity.downgrade(),
),
..Default::default()
};
let render_trailer =
move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
let crease = Crease::inline(
start..end,
placeholder.clone(),
fold_toggle("mention"),
render_trailer,
);
editor.insert_creases(vec![crease.clone()], cx);
editor.fold_creases(vec![crease], false, window, cx);
});
}
fn render_fold_icon_button(
icon_path: SharedString,
label: SharedString,
editor: WeakEntity<Editor>,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
Arc::new({
move |fold_id, fold_range, cx| {
let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
editor.update(cx, |editor, cx| {
let snapshot = editor
.buffer()
.update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
let is_in_pending_selection = || {
editor
.selections
.pending
.as_ref()
.is_some_and(|pending_selection| {
pending_selection
.selection
.range()
.includes(&fold_range, &snapshot)
})
};
let mut is_in_complete_selection = || {
editor
.selections
.disjoint_in_range::<usize>(fold_range.clone(), cx)
.into_iter()
.any(|selection| {
// This is needed to cover a corner case, if we just check for an existing
// selection in the fold range, having a cursor at the start of the fold
// marks it as selected. Non-empty selections don't cause this.
let length = selection.end - selection.start;
length > 0
})
};
is_in_pending_selection() || is_in_complete_selection()
})
});
ButtonLike::new(fold_id)
.style(ButtonStyle::Filled)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(is_in_text_selection)
.child(
h_flex()
.gap_1()
.child(
Icon::from_path(icon_path.clone())
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
Label::new(label.clone())
.size(LabelSize::Small)
.single_line(),
),
)
.into_any_element()
}
})
}
fn fold_toggle(
name: &'static str,
) -> impl Fn(
MultiBufferRow,
bool,
Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
&mut Window,
&mut App,
) -> AnyElement {
move |row, is_folded, fold, _window, _cx| {
Disclosure::new((name, row.0 as u64), !is_folded)
.toggle_state(is_folded)
.on_click(move |_e, window, cx| fold(!is_folded, window, cx))
.into_any_element()
}
}

File diff suppressed because it is too large Load diff

View file

@ -81,77 +81,80 @@ impl FetchContextPickerDelegate {
url: String::new(), url: String::new(),
} }
} }
}
async fn build_message(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> { pub(crate) async fn fetch_url_content(
let url = if !url.starts_with("https://") && !url.starts_with("http://") { http_client: Arc<HttpClientWithUrl>,
format!("https://{url}") url: String,
} else { ) -> Result<String> {
url let url = if !url.starts_with("https://") && !url.starts_with("http://") {
}; format!("https://{url}")
} else {
url
};
let mut response = http_client.get(&url, AsyncBody::default(), true).await?; let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
let mut body = Vec::new(); let mut body = Vec::new();
response response
.body_mut() .body_mut()
.read_to_end(&mut body) .read_to_end(&mut body)
.await .await
.context("error reading response body")?; .context("error reading response body")?;
if response.status().is_client_error() { if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice()); let text = String::from_utf8_lossy(body.as_slice());
bail!( bail!(
"status error {}, response: {text:?}", "status error {}, response: {text:?}",
response.status().as_u16() response.status().as_u16()
); );
}
let Some(content_type) = response.headers().get("content-type") else {
bail!("missing Content-Type header");
};
let content_type = content_type
.to_str()
.context("invalid Content-Type header")?;
let content_type = match content_type {
"text/html" => ContentType::Html,
"text/plain" => ContentType::Plaintext,
"application/json" => ContentType::Json,
_ => ContentType::Html,
};
match content_type {
ContentType::Html => {
let mut handlers: Vec<TagHandler> = vec![
Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
Rc::new(RefCell::new(markdown::ParagraphHandler)),
Rc::new(RefCell::new(markdown::HeadingHandler)),
Rc::new(RefCell::new(markdown::ListHandler)),
Rc::new(RefCell::new(markdown::TableHandler::new())),
Rc::new(RefCell::new(markdown::StyledTextHandler)),
];
if url.contains("wikipedia.org") {
use html_to_markdown::structure::wikipedia;
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
handlers.push(Rc::new(
RefCell::new(wikipedia::WikipediaCodeHandler::new()),
));
} else {
handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
}
convert_html_to_markdown(&body[..], &mut handlers)
} }
ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
ContentType::Json => {
let json: serde_json::Value = serde_json::from_slice(&body)?;
let Some(content_type) = response.headers().get("content-type") else { Ok(format!(
bail!("missing Content-Type header"); "```json\n{}\n```",
}; serde_json::to_string_pretty(&json)?
let content_type = content_type ))
.to_str()
.context("invalid Content-Type header")?;
let content_type = match content_type {
"text/html" => ContentType::Html,
"text/plain" => ContentType::Plaintext,
"application/json" => ContentType::Json,
_ => ContentType::Html,
};
match content_type {
ContentType::Html => {
let mut handlers: Vec<TagHandler> = vec![
Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
Rc::new(RefCell::new(markdown::ParagraphHandler)),
Rc::new(RefCell::new(markdown::HeadingHandler)),
Rc::new(RefCell::new(markdown::ListHandler)),
Rc::new(RefCell::new(markdown::TableHandler::new())),
Rc::new(RefCell::new(markdown::StyledTextHandler)),
];
if url.contains("wikipedia.org") {
use html_to_markdown::structure::wikipedia;
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
handlers.push(Rc::new(
RefCell::new(wikipedia::WikipediaCodeHandler::new()),
));
} else {
handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
}
convert_html_to_markdown(&body[..], &mut handlers)
}
ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
ContentType::Json => {
let json: serde_json::Value = serde_json::from_slice(&body)?;
Ok(format!(
"```json\n{}\n```",
serde_json::to_string_pretty(&json)?
))
}
} }
} }
} }
@ -208,7 +211,7 @@ impl PickerDelegate for FetchContextPickerDelegate {
let confirm_behavior = self.confirm_behavior; let confirm_behavior = self.confirm_behavior;
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let text = cx let text = cx
.background_spawn(Self::build_message(http_client, url.clone())) .background_spawn(fetch_url_content(http_client, url.clone()))
.await?; .await?;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {

View file

@ -1,25 +1,15 @@
use std::collections::BTreeSet;
use std::ops::Range;
use std::path::Path; use std::path::Path;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Arc; use std::sync::Arc;
use editor::actions::FoldAt;
use editor::display_map::{Crease, FoldId};
use editor::scroll::Autoscroll;
use editor::{Anchor, AnchorRangeExt, Editor, FoldPlaceholder, ToPoint};
use file_icons::FileIcons; use file_icons::FileIcons;
use fuzzy::PathMatch; use fuzzy::PathMatch;
use gpui::{ use gpui::{
AnyElement, App, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, Stateful, App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity,
Task, WeakEntity,
}; };
use multi_buffer::{MultiBufferPoint, MultiBufferRow};
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
use rope::Point; use ui::{prelude::*, ListItem, Tooltip};
use text::SelectionGoal;
use ui::{prelude::*, ButtonLike, Disclosure, ListItem, TintColor, Tooltip};
use util::ResultExt as _; use util::ResultExt as _;
use workspace::{notifications::NotifyResultExt, Workspace}; use workspace::{notifications::NotifyResultExt, Workspace};
@ -34,7 +24,6 @@ impl FileContextPicker {
pub fn new( pub fn new(
context_picker: WeakEntity<ContextPicker>, context_picker: WeakEntity<ContextPicker>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
context_store: WeakEntity<ContextStore>, context_store: WeakEntity<ContextStore>,
confirm_behavior: ConfirmBehavior, confirm_behavior: ConfirmBehavior,
window: &mut Window, window: &mut Window,
@ -43,7 +32,6 @@ impl FileContextPicker {
let delegate = FileContextPickerDelegate::new( let delegate = FileContextPickerDelegate::new(
context_picker, context_picker,
workspace, workspace,
editor,
context_store, context_store,
confirm_behavior, confirm_behavior,
); );
@ -68,7 +56,6 @@ impl Render for FileContextPicker {
pub struct FileContextPickerDelegate { pub struct FileContextPickerDelegate {
context_picker: WeakEntity<ContextPicker>, context_picker: WeakEntity<ContextPicker>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
context_store: WeakEntity<ContextStore>, context_store: WeakEntity<ContextStore>,
confirm_behavior: ConfirmBehavior, confirm_behavior: ConfirmBehavior,
matches: Vec<PathMatch>, matches: Vec<PathMatch>,
@ -79,95 +66,18 @@ impl FileContextPickerDelegate {
pub fn new( pub fn new(
context_picker: WeakEntity<ContextPicker>, context_picker: WeakEntity<ContextPicker>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
context_store: WeakEntity<ContextStore>, context_store: WeakEntity<ContextStore>,
confirm_behavior: ConfirmBehavior, confirm_behavior: ConfirmBehavior,
) -> Self { ) -> Self {
Self { Self {
context_picker, context_picker,
workspace, workspace,
editor,
context_store, context_store,
confirm_behavior, confirm_behavior,
matches: Vec::new(), matches: Vec::new(),
selected_index: 0, selected_index: 0,
} }
} }
fn search(
&mut self,
query: String,
cancellation_flag: Arc<AtomicBool>,
workspace: &Entity<Workspace>,
cx: &mut Context<Picker<Self>>,
) -> Task<Vec<PathMatch>> {
if query.is_empty() {
let workspace = workspace.read(cx);
let project = workspace.project().read(cx);
let recent_matches = workspace
.recent_navigation_history(Some(10), cx)
.into_iter()
.filter_map(|(project_path, _)| {
let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
Some(PathMatch {
score: 0.,
positions: Vec::new(),
worktree_id: project_path.worktree_id.to_usize(),
path: project_path.path,
path_prefix: worktree.read(cx).root_name().into(),
distance_to_relative_ancestor: 0,
is_dir: false,
})
});
let file_matches = project.worktrees(cx).flat_map(|worktree| {
let worktree = worktree.read(cx);
let path_prefix: Arc<str> = worktree.root_name().into();
worktree.entries(false, 0).map(move |entry| PathMatch {
score: 0.,
positions: Vec::new(),
worktree_id: worktree.id().to_usize(),
path: entry.path.clone(),
path_prefix: path_prefix.clone(),
distance_to_relative_ancestor: 0,
is_dir: entry.is_dir(),
})
});
Task::ready(recent_matches.chain(file_matches).collect())
} else {
let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
let candidate_sets = worktrees
.into_iter()
.map(|worktree| {
let worktree = worktree.read(cx);
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
.map_or(false, |entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
})
.collect::<Vec<_>>();
let executor = cx.background_executor().clone();
cx.foreground_executor().spawn(async move {
fuzzy::match_path_sets(
candidate_sets.as_slice(),
query.as_str(),
None,
false,
100,
&cancellation_flag,
executor,
)
.await
})
}
}
} }
impl PickerDelegate for FileContextPickerDelegate { impl PickerDelegate for FileContextPickerDelegate {
@ -204,7 +114,7 @@ impl PickerDelegate for FileContextPickerDelegate {
return Task::ready(()); return Task::ready(());
}; };
let search_task = self.search(query, Arc::<AtomicBool>::default(), &workspace, cx); let search_task = search_paths(query, Arc::<AtomicBool>::default(), &workspace, cx);
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
// TODO: This should be probably be run in the background. // TODO: This should be probably be run in the background.
@ -222,14 +132,6 @@ impl PickerDelegate for FileContextPickerDelegate {
return; return;
}; };
let file_name = mat
.path
.file_name()
.map(|os_str| os_str.to_string_lossy().into_owned())
.unwrap_or(mat.path_prefix.to_string());
let full_path = mat.path.display().to_string();
let project_path = ProjectPath { let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(mat.worktree_id), worktree_id: WorktreeId::from_usize(mat.worktree_id),
path: mat.path.clone(), path: mat.path.clone(),
@ -237,106 +139,13 @@ impl PickerDelegate for FileContextPickerDelegate {
let is_directory = mat.is_dir; let is_directory = mat.is_dir;
let Some(editor_entity) = self.editor.upgrade() else {
return;
};
editor_entity.update(cx, |editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
// Move empty selections left by 1 column to select the `@`s, so they get overwritten when we insert.
{
let mut selections = editor.selections.all::<MultiBufferPoint>(cx);
for selection in selections.iter_mut() {
if selection.is_empty() {
let old_head = selection.head();
let new_head = MultiBufferPoint::new(
old_head.row,
old_head.column.saturating_sub(1),
);
selection.set_head(new_head, SelectionGoal::None);
}
}
editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.select(selections)
});
}
let start_anchors = {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.selections
.all::<Point>(cx)
.into_iter()
.map(|selection| snapshot.anchor_before(selection.start))
.collect::<Vec<_>>()
};
editor.insert(&full_path, window, cx);
let end_anchors = {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.selections
.all::<Point>(cx)
.into_iter()
.map(|selection| snapshot.anchor_after(selection.end))
.collect::<Vec<_>>()
};
editor.insert("\n", window, cx); // Needed to end the fold
let file_icon = if is_directory {
FileIcons::get_folder_icon(false, cx)
} else {
FileIcons::get_icon(&Path::new(&full_path), cx)
}
.unwrap_or_else(|| SharedString::new(""));
let placeholder = FoldPlaceholder {
render: render_fold_icon_button(
file_icon,
file_name.into(),
editor_entity.downgrade(),
),
..Default::default()
};
let render_trailer =
move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
let buffer = editor.buffer().read(cx).snapshot(cx);
let mut rows_to_fold = BTreeSet::new();
let crease_iter = start_anchors
.into_iter()
.zip(end_anchors)
.map(|(start, end)| {
rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row));
Crease::inline(
start..end,
placeholder.clone(),
fold_toggle("tool-use"),
render_trailer,
)
});
editor.insert_creases(crease_iter, cx);
for buffer_row in rows_to_fold {
editor.fold_at(&FoldAt { buffer_row }, window, cx);
}
});
});
let Some(task) = self let Some(task) = self
.context_store .context_store
.update(cx, |context_store, cx| { .update(cx, |context_store, cx| {
if is_directory { if is_directory {
context_store.add_directory(project_path, cx) context_store.add_directory(project_path, true, cx)
} else { } else {
context_store.add_file_from_path(project_path, cx) context_store.add_file_from_path(project_path, true, cx)
} }
}) })
.ok() .ok()
@ -390,6 +199,80 @@ impl PickerDelegate for FileContextPickerDelegate {
} }
} }
pub(crate) fn search_paths(
query: String,
cancellation_flag: Arc<AtomicBool>,
workspace: &Entity<Workspace>,
cx: &App,
) -> Task<Vec<PathMatch>> {
if query.is_empty() {
let workspace = workspace.read(cx);
let project = workspace.project().read(cx);
let recent_matches = workspace
.recent_navigation_history(Some(10), cx)
.into_iter()
.filter_map(|(project_path, _)| {
let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
Some(PathMatch {
score: 0.,
positions: Vec::new(),
worktree_id: project_path.worktree_id.to_usize(),
path: project_path.path,
path_prefix: worktree.read(cx).root_name().into(),
distance_to_relative_ancestor: 0,
is_dir: false,
})
});
let file_matches = project.worktrees(cx).flat_map(|worktree| {
let worktree = worktree.read(cx);
let path_prefix: Arc<str> = worktree.root_name().into();
worktree.entries(false, 0).map(move |entry| PathMatch {
score: 0.,
positions: Vec::new(),
worktree_id: worktree.id().to_usize(),
path: entry.path.clone(),
path_prefix: path_prefix.clone(),
distance_to_relative_ancestor: 0,
is_dir: entry.is_dir(),
})
});
Task::ready(recent_matches.chain(file_matches).collect())
} else {
let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
let candidate_sets = worktrees
.into_iter()
.map(|worktree| {
let worktree = worktree.read(cx);
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
.map_or(false, |entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
})
.collect::<Vec<_>>();
let executor = cx.background_executor().clone();
cx.foreground_executor().spawn(async move {
fuzzy::match_path_sets(
candidate_sets.as_slice(),
query.as_str(),
None,
false,
100,
&cancellation_flag,
executor,
)
.await
})
}
}
pub fn render_file_context_entry( pub fn render_file_context_entry(
id: ElementId, id: ElementId,
path: &Path, path: &Path,
@ -484,85 +367,3 @@ pub fn render_file_context_entry(
} }
}) })
} }
fn render_fold_icon_button(
icon: SharedString,
label: SharedString,
editor: WeakEntity<Editor>,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
Arc::new(move |fold_id, fold_range, cx| {
let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
editor.update(cx, |editor, cx| {
let snapshot = editor
.buffer()
.update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
let is_in_pending_selection = || {
editor
.selections
.pending
.as_ref()
.is_some_and(|pending_selection| {
pending_selection
.selection
.range()
.includes(&fold_range, &snapshot)
})
};
let mut is_in_complete_selection = || {
editor
.selections
.disjoint_in_range::<usize>(fold_range.clone(), cx)
.into_iter()
.any(|selection| {
// This is needed to cover a corner case, if we just check for an existing
// selection in the fold range, having a cursor at the start of the fold
// marks it as selected. Non-empty selections don't cause this.
let length = selection.end - selection.start;
length > 0
})
};
is_in_pending_selection() || is_in_complete_selection()
})
});
ButtonLike::new(fold_id)
.style(ButtonStyle::Filled)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(is_in_text_selection)
.child(
h_flex()
.gap_1()
.child(
Icon::from_path(icon.clone())
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
Label::new(label.clone())
.size(LabelSize::Small)
.single_line(),
),
)
.into_any_element()
})
}
fn fold_toggle(
name: &'static str,
) -> impl Fn(
MultiBufferRow,
bool,
Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
&mut Window,
&mut App,
) -> AnyElement {
move |row, is_folded, fold, _window, _cx| {
Disclosure::new((name, row.0 as u64), !is_folded)
.toggle_state(is_folded)
.on_click(move |_e, window, cx| fold(!is_folded, window, cx))
.into_any_element()
}
}

View file

@ -110,45 +110,11 @@ impl PickerDelegate for ThreadContextPickerDelegate {
window: &mut Window, window: &mut Window,
cx: &mut Context<Picker<Self>>, cx: &mut Context<Picker<Self>>,
) -> Task<()> { ) -> Task<()> {
let Ok(threads) = self.thread_store.update(cx, |this, _cx| { let Some(threads) = self.thread_store.upgrade() else {
this.threads()
.into_iter()
.map(|thread| ThreadContextEntry {
id: thread.id,
summary: thread.summary,
})
.collect::<Vec<_>>()
}) else {
return Task::ready(()); return Task::ready(());
}; };
let executor = cx.background_executor().clone(); let search_task = search_threads(query, threads, cx);
let search_task = cx.background_spawn(async move {
if query.is_empty() {
threads
} else {
let candidates = threads
.iter()
.enumerate()
.map(|(id, thread)| StringMatchCandidate::new(id, &thread.summary))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
100,
&Default::default(),
executor,
)
.await;
matches
.into_iter()
.map(|mat| threads[mat.candidate_id].clone())
.collect()
}
});
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let matches = search_task.await; let matches = search_task.await;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@ -176,7 +142,9 @@ impl PickerDelegate for ThreadContextPickerDelegate {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.delegate this.delegate
.context_store .context_store
.update(cx, |context_store, cx| context_store.add_thread(thread, cx)) .update(cx, |context_store, cx| {
context_store.add_thread(thread, true, cx)
})
.ok(); .ok();
match this.delegate.confirm_behavior { match this.delegate.confirm_behavior {
@ -248,3 +216,46 @@ pub fn render_thread_context_entry(
) )
}) })
} }
pub(crate) fn search_threads(
query: String,
thread_store: Entity<ThreadStore>,
cx: &mut App,
) -> Task<Vec<ThreadContextEntry>> {
let threads = thread_store.update(cx, |this, _cx| {
this.threads()
.into_iter()
.map(|thread| ThreadContextEntry {
id: thread.id,
summary: thread.summary,
})
.collect::<Vec<_>>()
});
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
if query.is_empty() {
threads
} else {
let candidates = threads
.iter()
.enumerate()
.map(|(id, thread)| StringMatchCandidate::new(id, &thread.summary))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
100,
&Default::default(),
executor,
)
.await;
matches
.into_iter()
.map(|mat| threads[mat.candidate_id].clone())
.collect()
}
})
}

View file

@ -64,6 +64,7 @@ impl ContextStore {
pub fn add_file_from_path( pub fn add_file_from_path(
&mut self, &mut self,
project_path: ProjectPath, project_path: ProjectPath,
remove_if_exists: bool,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
@ -86,7 +87,9 @@ impl ContextStore {
let already_included = this.update(cx, |this, _cx| { let already_included = this.update(cx, |this, _cx| {
match this.will_include_buffer(buffer_id, &project_path.path) { match this.will_include_buffer(buffer_id, &project_path.path) {
Some(FileInclusion::Direct(context_id)) => { Some(FileInclusion::Direct(context_id)) => {
this.remove_context(context_id); if remove_if_exists {
this.remove_context(context_id);
}
true true
} }
Some(FileInclusion::InDirectory(_)) => true, Some(FileInclusion::InDirectory(_)) => true,
@ -157,6 +160,7 @@ impl ContextStore {
pub fn add_directory( pub fn add_directory(
&mut self, &mut self,
project_path: ProjectPath, project_path: ProjectPath,
remove_if_exists: bool,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
@ -169,7 +173,9 @@ impl ContextStore {
let already_included = if let Some(context_id) = self.includes_directory(&project_path.path) let already_included = if let Some(context_id) = self.includes_directory(&project_path.path)
{ {
self.remove_context(context_id); if remove_if_exists {
self.remove_context(context_id);
}
true true
} else { } else {
false false
@ -256,9 +262,16 @@ impl ContextStore {
))); )));
} }
pub fn add_thread(&mut self, thread: Entity<Thread>, cx: &mut Context<Self>) { pub fn add_thread(
&mut self,
thread: Entity<Thread>,
remove_if_exists: bool,
cx: &mut Context<Self>,
) {
if let Some(context_id) = self.includes_thread(&thread.read(cx).id()) { if let Some(context_id) = self.includes_thread(&thread.read(cx).id()) {
self.remove_context(context_id); if remove_if_exists {
self.remove_context(context_id);
}
} else { } else {
self.insert_thread(thread, cx); self.insert_thread(thread, cx);
} }

View file

@ -39,7 +39,6 @@ impl ContextStrip {
pub fn new( pub fn new(
context_store: Entity<ContextStore>, context_store: Entity<ContextStore>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
thread_store: Option<WeakEntity<ThreadStore>>, thread_store: Option<WeakEntity<ThreadStore>>,
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>, context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
suggest_context_kind: SuggestContextKind, suggest_context_kind: SuggestContextKind,
@ -51,7 +50,6 @@ impl ContextStrip {
workspace.clone(), workspace.clone(),
thread_store.clone(), thread_store.clone(),
context_store.downgrade(), context_store.downgrade(),
editor.clone(),
ConfirmBehavior::KeepOpen, ConfirmBehavior::KeepOpen,
window, window,
cx, cx,

View file

@ -861,7 +861,6 @@ impl PromptEditor<BufferCodegen> {
ContextStrip::new( ContextStrip::new(
context_store.clone(), context_store.clone(),
workspace.clone(), workspace.clone(),
prompt_editor.downgrade(),
thread_store.clone(), thread_store.clone(),
context_picker_menu_handle.clone(), context_picker_menu_handle.clone(),
SuggestContextKind::Thread, SuggestContextKind::Thread,
@ -1014,7 +1013,6 @@ impl PromptEditor<TerminalCodegen> {
ContextStrip::new( ContextStrip::new(
context_store.clone(), context_store.clone(),
workspace.clone(), workspace.clone(),
prompt_editor.downgrade(),
thread_store.clone(), thread_store.clone(),
context_picker_menu_handle.clone(), context_picker_menu_handle.clone(),
SuggestContextKind::Thread, SuggestContextKind::Thread,

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use collections::HashSet; use collections::HashSet;
use editor::actions::MoveUp; use editor::actions::MoveUp;
use editor::{Editor, EditorElement, EditorEvent, EditorStyle}; use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
use fs::Fs; use fs::Fs;
use git::ExpandCommitEditor; use git::ExpandCommitEditor;
use git_ui::git_panel; use git_ui::git_panel;
@ -13,10 +13,8 @@ use gpui::{
use language_model::LanguageModelRegistry; use language_model::LanguageModelRegistry;
use language_model_selector::ToggleModelSelector; use language_model_selector::ToggleModelSelector;
use project::Project; use project::Project;
use rope::Point;
use settings::Settings; use settings::Settings;
use std::time::Duration; use std::time::Duration;
use text::Bias;
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{ use ui::{
prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip,
@ -25,7 +23,7 @@ use vim_mode_setting::VimModeSetting;
use workspace::Workspace; use workspace::Workspace;
use crate::assistant_model_selector::AssistantModelSelector; use crate::assistant_model_selector::AssistantModelSelector;
use crate::context_picker::{ConfirmBehavior, ContextPicker}; use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider};
use crate::context_store::{refresh_context_store_text, ContextStore}; use crate::context_store::{refresh_context_store_text, ContextStore};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
use crate::thread::{RequestKind, Thread}; use crate::thread::{RequestKind, Thread};
@ -68,16 +66,30 @@ impl MessageEditor {
let mut editor = Editor::auto_height(10, window, cx); let mut editor = Editor::auto_height(10, window, cx);
editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx); editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
editor.set_show_indent_guides(false, cx); editor.set_show_indent_guides(false, cx);
editor.set_context_menu_options(ContextMenuOptions {
min_entries_visible: 12,
max_entries_visible: 12,
placement: Some(ContextMenuPlacement::Above),
});
editor editor
}); });
let editor_entity = editor.downgrade();
editor.update(cx, |editor, _| {
editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
workspace.clone(),
context_store.downgrade(),
Some(thread_store.clone()),
editor_entity,
))));
});
let inline_context_picker = cx.new(|cx| { let inline_context_picker = cx.new(|cx| {
ContextPicker::new( ContextPicker::new(
workspace.clone(), workspace.clone(),
Some(thread_store.clone()), Some(thread_store.clone()),
context_store.downgrade(), context_store.downgrade(),
editor.downgrade(),
ConfirmBehavior::Close, ConfirmBehavior::Close,
window, window,
cx, cx,
@ -88,7 +100,6 @@ impl MessageEditor {
ContextStrip::new( ContextStrip::new(
context_store.clone(), context_store.clone(),
workspace.clone(), workspace.clone(),
editor.downgrade(),
Some(thread_store.clone()), Some(thread_store.clone()),
context_picker_menu_handle.clone(), context_picker_menu_handle.clone(),
SuggestContextKind::File, SuggestContextKind::File,
@ -98,7 +109,6 @@ impl MessageEditor {
}); });
let subscriptions = vec![ let subscriptions = vec![
cx.subscribe_in(&editor, window, Self::handle_editor_event),
cx.subscribe_in( cx.subscribe_in(
&inline_context_picker, &inline_context_picker,
window, window,
@ -232,34 +242,6 @@ impl MessageEditor {
.detach(); .detach();
} }
fn handle_editor_event(
&mut self,
editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::SelectionsChanged { .. } => {
editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let newest_cursor = editor.selections.newest::<Point>(cx).head();
if newest_cursor.column > 0 {
let behind_cursor = snapshot.clip_point(
Point::new(newest_cursor.row, newest_cursor.column - 1),
Bias::Left,
);
let char_behind_cursor = snapshot.chars_at(behind_cursor).next();
if char_behind_cursor == Some('@') {
self.inline_context_picker_menu_handle.show(window, cx);
}
}
});
}
_ => {}
}
}
fn handle_inline_context_picker_event( fn handle_inline_context_picker_event(
&mut self, &mut self,
_inline_context_picker: &Entity<ContextPicker>, _inline_context_picker: &Entity<ContextPicker>,
@ -616,6 +598,7 @@ impl Render for MessageEditor {
background: editor_bg_color, background: editor_bg_color,
local_player: cx.theme().players().local(), local_player: cx.theme().players().local(),
text: text_style, text: text_style,
syntax: cx.theme().syntax().clone(),
..Default::default() ..Default::default()
}, },
) )

View file

@ -2,7 +2,7 @@ use crate::context_editor::ContextEditor;
use anyhow::Result; use anyhow::Result;
pub use assistant_slash_command::SlashCommand; pub use assistant_slash_command::SlashCommand;
use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWorkingSet}; use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWorkingSet};
use editor::{CompletionProvider, Editor}; use editor::{CompletionProvider, Editor, ExcerptId};
use fuzzy::{match_strings, StringMatchCandidate}; use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window}; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
use language::{Anchor, Buffer, ToPoint}; use language::{Anchor, Buffer, ToPoint};
@ -126,6 +126,7 @@ impl SlashCommandCompletionProvider {
)), )),
new_text, new_text,
label: command.label(cx), label: command.label(cx),
icon_path: None,
confirm, confirm,
source: CompletionSource::Custom, source: CompletionSource::Custom,
}) })
@ -223,6 +224,7 @@ impl SlashCommandCompletionProvider {
last_argument_range.clone() last_argument_range.clone()
}, },
label: new_argument.label, label: new_argument.label,
icon_path: None,
new_text, new_text,
documentation: None, documentation: None,
confirm, confirm,
@ -241,6 +243,7 @@ impl SlashCommandCompletionProvider {
impl CompletionProvider for SlashCommandCompletionProvider { impl CompletionProvider for SlashCommandCompletionProvider {
fn completions( fn completions(
&self, &self,
_excerpt_id: ExcerptId,
buffer: &Entity<Buffer>, buffer: &Entity<Buffer>,
buffer_position: Anchor, buffer_position: Anchor,
_: editor::CompletionContext, _: editor::CompletionContext,

View file

@ -2,7 +2,7 @@ use anyhow::{Context as _, Result};
use channel::{ChannelChat, ChannelStore, MessageParams}; use channel::{ChannelChat, ChannelStore, MessageParams};
use client::{UserId, UserStore}; use client::{UserId, UserStore};
use collections::HashSet; use collections::HashSet;
use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle}; use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight, AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight,
@ -56,6 +56,7 @@ struct MessageEditorCompletionProvider(WeakEntity<MessageEditor>);
impl CompletionProvider for MessageEditorCompletionProvider { impl CompletionProvider for MessageEditorCompletionProvider {
fn completions( fn completions(
&self, &self,
_excerpt_id: ExcerptId,
buffer: &Entity<Buffer>, buffer: &Entity<Buffer>,
buffer_position: language::Anchor, buffer_position: language::Anchor,
_: editor::CompletionContext, _: editor::CompletionContext,
@ -311,6 +312,7 @@ impl MessageEditor {
old_range: range.clone(), old_range: range.clone(),
new_text, new_text,
label, label,
icon_path: None,
confirm: None, confirm: None,
documentation: None, documentation: None,
source: CompletionSource::Custom, source: CompletionSource::Custom,

View file

@ -5,7 +5,7 @@ use super::{
use anyhow::Result; use anyhow::Result;
use collections::HashMap; use collections::HashMap;
use dap::OutputEvent; use dap::OutputEvent;
use editor::{CompletionProvider, Editor, EditorElement, EditorStyle}; use editor::{CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
use fuzzy::StringMatchCandidate; use fuzzy::StringMatchCandidate;
use gpui::{Context, Entity, Render, Subscription, Task, TextStyle, WeakEntity}; use gpui::{Context, Entity, Render, Subscription, Task, TextStyle, WeakEntity};
use language::{Buffer, CodeLabel}; use language::{Buffer, CodeLabel};
@ -246,6 +246,7 @@ struct ConsoleQueryBarCompletionProvider(WeakEntity<Console>);
impl CompletionProvider for ConsoleQueryBarCompletionProvider { impl CompletionProvider for ConsoleQueryBarCompletionProvider {
fn completions( fn completions(
&self, &self,
_excerpt_id: ExcerptId,
buffer: &Entity<Buffer>, buffer: &Entity<Buffer>,
buffer_position: language::Anchor, buffer_position: language::Anchor,
_trigger: editor::CompletionContext, _trigger: editor::CompletionContext,
@ -367,6 +368,7 @@ impl ConsoleQueryBarCompletionProvider {
text: format!("{} {}", string_match.string.clone(), variable_value), text: format!("{} {}", string_match.string.clone(), variable_value),
runs: Vec::new(), runs: Vec::new(),
}, },
icon_path: None,
documentation: None, documentation: None,
confirm: None, confirm: None,
source: project::CompletionSource::Custom, source: project::CompletionSource::Custom,
@ -408,6 +410,7 @@ impl ConsoleQueryBarCompletionProvider {
text: completion.label.clone(), text: completion.label.clone(),
runs: Vec::new(), runs: Vec::new(),
}, },
icon_path: None,
documentation: None, documentation: None,
confirm: None, confirm: None,
source: project::CompletionSource::Custom, source: project::CompletionSource::Custom,

View file

@ -1,6 +1,6 @@
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, Focusable, FontWeight, div, px, uniform_list, AnyElement, BackgroundExecutor, Entity, Focusable, FontWeight,
ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText, ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText,
UniformListScrollHandle, UniformListScrollHandle,
}; };
@ -236,6 +236,7 @@ impl CompletionsMenu {
runs: Default::default(), runs: Default::default(),
filter_range: Default::default(), filter_range: Default::default(),
}, },
icon_path: None,
documentation: None, documentation: None,
confirm: None, confirm: None,
source: CompletionSource::Custom, source: CompletionSource::Custom,
@ -539,9 +540,17 @@ impl CompletionsMenu {
} else { } else {
None None
}; };
let color_swatch = completion
let start_slot = completion
.color() .color()
.map(|color| div().size_4().bg(color).rounded_xs()); .map(|color| div().size_4().bg(color).rounded_xs().into_any_element())
.or_else(|| {
completion.icon_path.as_ref().map(|path| {
Icon::from_path(path)
.size(IconSize::Small)
.into_any_element()
})
});
div().min_w(px(280.)).max_w(px(540.)).child( div().min_w(px(280.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id) ListItem::new(mat.candidate_id)
@ -559,7 +568,7 @@ impl CompletionsMenu {
task.detach_and_log_err(cx) task.detach_and_log_err(cx)
} }
})) }))
.start_slot::<Div>(color_swatch) .start_slot::<AnyElement>(start_slot)
.child(h_flex().overflow_hidden().child(completion_label)) .child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label), .end_slot::<Label>(documentation_label),
) )

View file

@ -531,6 +531,18 @@ impl EditPredictionPreview {
} }
} }
pub struct ContextMenuOptions {
pub min_entries_visible: usize,
pub max_entries_visible: usize,
pub placement: Option<ContextMenuPlacement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuPlacement {
Above,
Below,
}
#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)] #[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)]
struct EditorActionId(usize); struct EditorActionId(usize);
@ -677,6 +689,7 @@ pub struct Editor {
active_indent_guides_state: ActiveIndentGuidesState, active_indent_guides_state: ActiveIndentGuidesState,
nav_history: Option<ItemNavHistory>, nav_history: Option<ItemNavHistory>,
context_menu: RefCell<Option<CodeContextMenu>>, context_menu: RefCell<Option<CodeContextMenu>>,
context_menu_options: Option<ContextMenuOptions>,
mouse_context_menu: Option<MouseContextMenu>, mouse_context_menu: Option<MouseContextMenu>,
completion_tasks: Vec<(CompletionId, Task<Option<()>>)>, completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
signature_help_state: SignatureHelpState, signature_help_state: SignatureHelpState,
@ -1441,6 +1454,7 @@ impl Editor {
active_indent_guides_state: ActiveIndentGuidesState::default(), active_indent_guides_state: ActiveIndentGuidesState::default(),
nav_history: None, nav_history: None,
context_menu: RefCell::new(None), context_menu: RefCell::new(None),
context_menu_options: None,
mouse_context_menu: None, mouse_context_menu: None,
completion_tasks: Default::default(), completion_tasks: Default::default(),
signature_help_state: SignatureHelpState::default(), signature_help_state: SignatureHelpState::default(),
@ -4251,8 +4265,14 @@ impl Editor {
let (mut words, provided_completions) = match provider { let (mut words, provided_completions) = match provider {
Some(provider) => { Some(provider) => {
let completions = let completions = provider.completions(
provider.completions(&buffer, buffer_position, completion_context, window, cx); position.excerpt_id,
&buffer,
buffer_position,
completion_context,
window,
cx,
);
let words = match completion_settings.words { let words = match completion_settings.words {
WordsCompletionMode::Disabled => Task::ready(HashMap::default()), WordsCompletionMode::Disabled => Task::ready(HashMap::default()),
@ -4310,6 +4330,7 @@ impl Editor {
old_range: old_range.clone(), old_range: old_range.clone(),
new_text: word.clone(), new_text: word.clone(),
label: CodeLabel::plain(word, None), label: CodeLabel::plain(word, None),
icon_path: None,
documentation: None, documentation: None,
source: CompletionSource::BufferWord { source: CompletionSource::BufferWord {
word_range, word_range,
@ -4384,6 +4405,17 @@ impl Editor {
self.completion_tasks.push((id, task)); self.completion_tasks.push((id, task));
} }
#[cfg(feature = "test-support")]
pub fn current_completions(&self) -> Option<Vec<project::Completion>> {
let menu = self.context_menu.borrow();
if let CodeContextMenu::Completions(menu) = menu.as_ref()? {
let completions = menu.completions.borrow();
Some(completions.to_vec())
} else {
None
}
}
pub fn confirm_completion( pub fn confirm_completion(
&mut self, &mut self,
action: &ConfirmCompletion, action: &ConfirmCompletion,
@ -6435,6 +6467,10 @@ impl Editor {
.map(|menu| menu.origin()) .map(|menu| menu.origin())
} }
pub fn set_context_menu_options(&mut self, options: ContextMenuOptions) {
self.context_menu_options = Some(options);
}
const EDIT_PREDICTION_POPOVER_PADDING_X: Pixels = Pixels(24.); const EDIT_PREDICTION_POPOVER_PADDING_X: Pixels = Pixels(24.);
const EDIT_PREDICTION_POPOVER_PADDING_Y: Pixels = Pixels(2.); const EDIT_PREDICTION_POPOVER_PADDING_Y: Pixels = Pixels(2.);
@ -17857,6 +17893,7 @@ pub trait SemanticsProvider {
pub trait CompletionProvider { pub trait CompletionProvider {
fn completions( fn completions(
&self, &self,
excerpt_id: ExcerptId,
buffer: &Entity<Buffer>, buffer: &Entity<Buffer>,
buffer_position: text::Anchor, buffer_position: text::Anchor,
trigger: CompletionContext, trigger: CompletionContext,
@ -18090,6 +18127,7 @@ fn snippet_completions(
runs: Vec::new(), runs: Vec::new(),
filter_range: 0..matching_prefix.len(), filter_range: 0..matching_prefix.len(),
}, },
icon_path: None,
documentation: snippet documentation: snippet
.description .description
.clone() .clone()
@ -18106,6 +18144,7 @@ fn snippet_completions(
impl CompletionProvider for Entity<Project> { impl CompletionProvider for Entity<Project> {
fn completions( fn completions(
&self, &self,
_excerpt_id: ExcerptId,
buffer: &Entity<Buffer>, buffer: &Entity<Buffer>,
buffer_position: text::Anchor, buffer_position: text::Anchor,
options: CompletionContext, options: CompletionContext,

View file

@ -16,15 +16,15 @@ use crate::{
items::BufferSearchHighlights, items::BufferSearchHighlights,
mouse_context_menu::{self, MenuPosition, MouseContextMenu}, mouse_context_menu::{self, MenuPosition, MouseContextMenu},
scroll::{axis_pair, scroll_amount::ScrollAmount, AxisPair}, scroll::{axis_pair, scroll_amount::ScrollAmount, AxisPair},
BlockId, ChunkReplacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, BlockId, ChunkReplacement, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk,
DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode,
EditorSettings, EditorSnapshot, EditorStyle, FocusedBlock, GoToHunk, GoToPreviousHunk, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FocusedBlock, GoToHunk,
GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason, GoToPreviousHunk, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor,
InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, OpenExcerpts, PageDown, PageUp, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp,
Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap, OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight,
StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR, Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS,
FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
}; };
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use client::ParticipantIndex; use client::ParticipantIndex;
@ -3338,6 +3338,7 @@ impl EditorElement {
let height_below_menu = Pixels::ZERO; let height_below_menu = Pixels::ZERO;
let mut edit_prediction_popover_visible = false; let mut edit_prediction_popover_visible = false;
let mut context_menu_visible = false; let mut context_menu_visible = false;
let context_menu_placement;
{ {
let editor = self.editor.read(cx); let editor = self.editor.read(cx);
@ -3351,11 +3352,22 @@ impl EditorElement {
if editor.context_menu_visible() { if editor.context_menu_visible() {
if let Some(crate::ContextMenuOrigin::Cursor) = editor.context_menu_origin() { if let Some(crate::ContextMenuOrigin::Cursor) = editor.context_menu_origin() {
min_menu_height += line_height * 3. + POPOVER_Y_PADDING; let (min_height_in_lines, max_height_in_lines) = editor
max_menu_height += line_height * 12. + POPOVER_Y_PADDING; .context_menu_options
.as_ref()
.map_or((3, 12), |options| {
(options.min_entries_visible, options.max_entries_visible)
});
min_menu_height += line_height * min_height_in_lines as f32 + POPOVER_Y_PADDING;
max_menu_height += line_height * max_height_in_lines as f32 + POPOVER_Y_PADDING;
context_menu_visible = true; context_menu_visible = true;
} }
} }
context_menu_placement = editor
.context_menu_options
.as_ref()
.and_then(|options| options.placement.clone());
} }
let visible = edit_prediction_popover_visible || context_menu_visible; let visible = edit_prediction_popover_visible || context_menu_visible;
@ -3390,6 +3402,7 @@ impl EditorElement {
line_height, line_height,
min_height, min_height,
max_height, max_height,
context_menu_placement,
text_hitbox, text_hitbox,
viewport_bounds, viewport_bounds,
window, window,
@ -3532,8 +3545,16 @@ impl EditorElement {
x: -gutter_overshoot, x: -gutter_overshoot,
y: gutter_row.next_row().as_f32() * line_height - scroll_pixel_position.y, y: gutter_row.next_row().as_f32() * line_height - scroll_pixel_position.y,
}; };
let min_height = line_height * 3. + POPOVER_Y_PADDING;
let max_height = line_height * 12. + POPOVER_Y_PADDING; let (min_height_in_lines, max_height_in_lines) = editor
.context_menu_options
.as_ref()
.map_or((3, 12), |options| {
(options.min_entries_visible, options.max_entries_visible)
});
let min_height = line_height * min_height_in_lines as f32 + POPOVER_Y_PADDING;
let max_height = line_height * max_height_in_lines as f32 + POPOVER_Y_PADDING;
let viewport_bounds = let viewport_bounds =
Bounds::new(Default::default(), window.viewport_size()).extend(Edges { Bounds::new(Default::default(), window.viewport_size()).extend(Edges {
right: -Self::SCROLLBAR_WIDTH - MENU_GAP, right: -Self::SCROLLBAR_WIDTH - MENU_GAP,
@ -3544,6 +3565,10 @@ impl EditorElement {
line_height, line_height,
min_height, min_height,
max_height, max_height,
editor
.context_menu_options
.as_ref()
.and_then(|options| options.placement.clone()),
text_hitbox, text_hitbox,
viewport_bounds, viewport_bounds,
window, window,
@ -3564,6 +3589,7 @@ impl EditorElement {
line_height: Pixels, line_height: Pixels,
min_height: Pixels, min_height: Pixels,
max_height: Pixels, max_height: Pixels,
placement: Option<ContextMenuPlacement>,
text_hitbox: &Hitbox, text_hitbox: &Hitbox,
viewport_bounds: Bounds<Pixels>, viewport_bounds: Bounds<Pixels>,
window: &mut Window, window: &mut Window,
@ -3588,7 +3614,11 @@ impl EditorElement {
let available_above = bottom_y_when_flipped - text_hitbox.top(); let available_above = bottom_y_when_flipped - text_hitbox.top();
let available_below = text_hitbox.bottom() - target_position.y; let available_below = text_hitbox.bottom() - target_position.y;
let y_overflows_below = max_height > available_below; let y_overflows_below = max_height > available_below;
let mut y_flipped = y_overflows_below && available_above > available_below; let mut y_flipped = match placement {
Some(ContextMenuPlacement::Above) => true,
Some(ContextMenuPlacement::Below) => false,
None => y_overflows_below && available_above > available_below,
};
let mut height = cmp::min( let mut height = cmp::min(
max_height, max_height,
if y_flipped { if y_flipped {
@ -3602,19 +3632,27 @@ impl EditorElement {
if height < min_height { if height < min_height {
let available_above = bottom_y_when_flipped; let available_above = bottom_y_when_flipped;
let available_below = viewport_bounds.bottom() - target_position.y; let available_below = viewport_bounds.bottom() - target_position.y;
if available_below > min_height { let (y_flipped_override, height_override) = match placement {
y_flipped = false; Some(ContextMenuPlacement::Above) => {
height = min_height; (true, cmp::min(available_above, min_height))
} else if available_above > min_height { }
y_flipped = true; Some(ContextMenuPlacement::Below) => {
height = min_height; (false, cmp::min(available_below, min_height))
} else if available_above > available_below { }
y_flipped = true; None => {
height = available_above; if available_below > min_height {
} else { (false, min_height)
y_flipped = false; } else if available_above > min_height {
height = available_below; (true, min_height)
} } else if available_above > available_below {
(true, available_above)
} else {
(false, available_below)
}
}
};
y_flipped = y_flipped_override;
height = height_override;
} }
let max_width_for_stable_x = viewport_bounds.right() - target_position.x; let max_width_for_stable_x = viewport_bounds.right() - target_position.x;

View file

@ -7872,6 +7872,7 @@ impl LspStore {
runs: Default::default(), runs: Default::default(),
filter_range: Default::default(), filter_range: Default::default(),
}, },
icon_path: None,
confirm: None, confirm: None,
}]))), }]))),
0, 0,
@ -9098,6 +9099,7 @@ async fn populate_labels_for_completions(
old_range: completion.old_range, old_range: completion.old_range,
new_text: completion.new_text, new_text: completion.new_text,
source: completion.source, source: completion.source,
icon_path: None,
confirm: None, confirm: None,
}); });
} }
@ -9110,6 +9112,7 @@ async fn populate_labels_for_completions(
old_range: completion.old_range, old_range: completion.old_range,
new_text: completion.new_text, new_text: completion.new_text,
source: completion.source, source: completion.source,
icon_path: None,
confirm: None, confirm: None,
}); });
} }

View file

@ -390,6 +390,8 @@ pub struct Completion {
pub documentation: Option<CompletionDocumentation>, pub documentation: Option<CompletionDocumentation>,
/// Completion data source which it was constructed from. /// Completion data source which it was constructed from.
pub source: CompletionSource, pub source: CompletionSource,
/// A path to an icon for this completion that is shown in the menu.
pub icon_path: Option<SharedString>,
/// An optional callback to invoke when this completion is confirmed. /// An optional callback to invoke when this completion is confirmed.
/// Returns, whether new completions should be retriggered after the current one. /// Returns, whether new completions should be retriggered after the current one.
/// If `true` is returned, the editor will show a new completion menu after this completion is confirmed. /// If `true` is returned, the editor will show a new completion menu after this completion is confirmed.

View file

@ -374,7 +374,7 @@ enum IconSource {
impl IconSource { impl IconSource {
fn from_path(path: impl Into<SharedString>) -> Self { fn from_path(path: impl Into<SharedString>) -> Self {
let path = path.into(); let path = path.into();
if path.starts_with("icons/file_icons") { if path.starts_with("icons/") {
Self::Svg(path) Self::Svg(path)
} else { } else {
Self::Image(Arc::from(PathBuf::from(path.as_ref()))) Self::Image(Arc::from(PathBuf::from(path.as_ref())))