From a267911e83742c741284aa737aa5c6d5baa5cd16 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 10 Jan 2025 11:26:53 -0300 Subject: [PATCH] assistant2: Suggest recent files and threads as context (#22959) The context picker will now display up to 6 recent files/threads to add as a context: Note: We decided to use a `ContextMenu` instead of `Picker` for the initial one since the latter didn't quite fit the design for the "Recent" section. Release Notes: - N/A --------- Co-authored-by: Danilo Co-authored-by: Piotr Co-authored-by: Nathan --- crates/assistant2/src/active_thread.rs | 4 + crates/assistant2/src/assistant_panel.rs | 9 +- crates/assistant2/src/context.rs | 18 + crates/assistant2/src/context_picker.rs | 486 +++++++++++------- .../directory_context_picker.rs | 2 +- .../context_picker/fetch_context_picker.rs | 2 +- .../src/context_picker/file_context_picker.rs | 165 +++--- .../context_picker/thread_context_picker.rs | 68 +-- crates/assistant2/src/context_store.rs | 19 +- crates/assistant2/src/context_strip.rs | 12 +- crates/assistant2/src/thread.rs | 5 + crates/assistant2/src/thread_history.rs | 6 +- crates/ui/src/components/context_menu.rs | 176 +++++-- crates/ui/src/utils/with_rem_size.rs | 12 +- crates/workspace/src/workspace.rs | 15 +- 15 files changed, 649 insertions(+), 350 deletions(-) diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index bcbc1e8431..e56d766ea1 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -76,6 +76,10 @@ impl ActiveThread { self.thread.read(cx).summary() } + pub fn summary_or_default(&self, cx: &AppContext) -> SharedString { + self.thread.read(cx).summary_or_default() + } + pub fn last_error(&self) -> Option { self.last_error.clone() } diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 64135bfce7..2d1d7adb29 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -300,11 +300,12 @@ impl AssistantPanel { fn render_toolbar(&self, cx: &mut ViewContext) -> impl IntoElement { let focus_handle = self.focus_handle(cx); - let title = if self.thread.read(cx).is_empty() { - SharedString::from("New Thread") + let thread = self.thread.read(cx); + + let title = if thread.is_empty() { + thread.summary_or_default(cx) } else { - self.thread - .read(cx) + thread .summary(cx) .unwrap_or_else(|| SharedString::from("Loading Summary…")) }; diff --git a/crates/assistant2/src/context.rs b/crates/assistant2/src/context.rs index a69c935866..a31c25012d 100644 --- a/crates/assistant2/src/context.rs +++ b/crates/assistant2/src/context.rs @@ -43,6 +43,24 @@ pub enum ContextKind { } impl ContextKind { + pub fn all() -> &'static [ContextKind] { + &[ + ContextKind::File, + ContextKind::Directory, + ContextKind::FetchedUrl, + ContextKind::Thread, + ] + } + + pub fn label(&self) -> &'static str { + match self { + ContextKind::File => "File", + ContextKind::Directory => "Folder", + ContextKind::FetchedUrl => "Fetch", + ContextKind::Thread => "Thread", + } + } + pub fn icon(&self) -> IconName { match self { ContextKind::File => IconName::File, diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs index c9bfe07f56..85fc282ca2 100644 --- a/crates/assistant2/src/context_picker.rs +++ b/crates/assistant2/src/context_picker.rs @@ -3,15 +3,17 @@ mod fetch_context_picker; mod file_context_picker; mod thread_context_picker; +use std::path::PathBuf; use std::sync::Arc; +use editor::Editor; +use file_context_picker::render_file_context_entry; use gpui::{ - AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, SharedString, Task, View, - WeakModel, WeakView, + AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, View, WeakModel, WeakView, }; -use picker::{Picker, PickerDelegate}; -use ui::{prelude::*, ListItem, ListItemSpacing}; -use util::ResultExt; +use project::ProjectPath; +use thread_context_picker::{render_thread_context_entry, ThreadContextEntry}; +use ui::{prelude::*, ContextMenu, ContextMenuEntry, ContextMenuItem}; use workspace::Workspace; use crate::context::ContextKind; @@ -21,6 +23,7 @@ use crate::context_picker::file_context_picker::FileContextPicker; use crate::context_picker::thread_context_picker::ThreadContextPicker; use crate::context_store::ContextStore; use crate::thread_store::ThreadStore; +use crate::AssistantPanel; #[derive(Debug, Clone, Copy)] pub enum ConfirmBehavior { @@ -30,7 +33,7 @@ pub enum ConfirmBehavior { #[derive(Debug, Clone)] enum ContextPickerMode { - Default, + Default(View), File(View), Directory(View), Fetch(View), @@ -39,7 +42,10 @@ enum ContextPickerMode { pub(super) struct ContextPicker { mode: ContextPickerMode, - picker: View>, + workspace: WeakView, + context_store: WeakModel, + thread_store: Option>, + confirm_behavior: ConfirmBehavior, } impl ContextPicker { @@ -50,53 +56,287 @@ impl ContextPicker { confirm_behavior: ConfirmBehavior, cx: &mut ViewContext, ) -> Self { - let mut entries = Vec::new(); - entries.push(ContextPickerEntry { - name: "File".into(), - kind: ContextKind::File, - icon: IconName::File, - }); - entries.push(ContextPickerEntry { - name: "Folder".into(), - kind: ContextKind::Directory, - icon: IconName::Folder, - }); - entries.push(ContextPickerEntry { - name: "Fetch".into(), - kind: ContextKind::FetchedUrl, - icon: IconName::Globe, - }); - - if thread_store.is_some() { - entries.push(ContextPickerEntry { - name: "Thread".into(), - kind: ContextKind::Thread, - icon: IconName::MessageCircle, - }); - } - - let delegate = ContextPickerDelegate { - context_picker: cx.view().downgrade(), - workspace, - thread_store, - context_store, - confirm_behavior, - entries, - selected_ix: 0, - }; - - let picker = cx.new_view(|cx| { - Picker::nonsearchable_uniform_list(delegate, cx).max_height(Some(rems(20.).into())) - }); - ContextPicker { - mode: ContextPickerMode::Default, - picker, + mode: ContextPickerMode::Default(ContextMenu::build(cx, |menu, _cx| menu)), + workspace, + context_store, + thread_store, + confirm_behavior, } } - pub fn reset_mode(&mut self) { - self.mode = ContextPickerMode::Default; + pub fn reset_mode(&mut self, cx: &mut ViewContext) { + self.mode = ContextPickerMode::Default(self.build(cx)); + } + + fn build(&mut self, cx: &mut ViewContext) -> View { + let context_picker = cx.view().clone(); + + ContextMenu::build(cx, move |menu, cx| { + let kind_entry = |kind: &'static ContextKind| { + let context_picker = context_picker.clone(); + + ContextMenuEntry::new(kind.label()) + .icon(kind.icon()) + .handler(move |cx| { + context_picker.update(cx, |this, cx| this.select_kind(*kind, cx)) + }) + }; + + let recent = self.recent_entries(cx); + let has_recent = !recent.is_empty(); + let recent_entries = recent + .into_iter() + .enumerate() + .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry)); + + menu.when(has_recent, |menu| menu.label("Recent")) + .extend(recent_entries) + .when(has_recent, |menu| menu.separator()) + .extend(ContextKind::all().into_iter().map(kind_entry)) + }) + } + + fn select_kind(&mut self, kind: ContextKind, cx: &mut ViewContext) { + let context_picker = cx.view().downgrade(); + + match kind { + ContextKind::File => { + self.mode = ContextPickerMode::File(cx.new_view(|cx| { + FileContextPicker::new( + context_picker.clone(), + self.workspace.clone(), + self.context_store.clone(), + self.confirm_behavior, + cx, + ) + })); + } + ContextKind::Directory => { + self.mode = ContextPickerMode::Directory(cx.new_view(|cx| { + DirectoryContextPicker::new( + context_picker.clone(), + self.workspace.clone(), + self.context_store.clone(), + self.confirm_behavior, + cx, + ) + })); + } + ContextKind::FetchedUrl => { + self.mode = ContextPickerMode::Fetch(cx.new_view(|cx| { + FetchContextPicker::new( + context_picker.clone(), + self.workspace.clone(), + self.context_store.clone(), + self.confirm_behavior, + cx, + ) + })); + } + ContextKind::Thread => { + if let Some(thread_store) = self.thread_store.as_ref() { + self.mode = ContextPickerMode::Thread(cx.new_view(|cx| { + ThreadContextPicker::new( + thread_store.clone(), + context_picker.clone(), + self.context_store.clone(), + self.confirm_behavior, + cx, + ) + })); + } + } + } + + cx.notify(); + cx.focus_self(); + } + + fn recent_menu_item( + &self, + context_picker: View, + ix: usize, + entry: RecentEntry, + ) -> ContextMenuItem { + match entry { + RecentEntry::File { + project_path, + path_prefix, + } => { + let context_store = self.context_store.clone(); + let path = project_path.path.clone(); + + ContextMenuItem::custom_entry( + move |cx| { + render_file_context_entry( + ElementId::NamedInteger("ctx-recent".into(), ix), + &path, + &path_prefix, + context_store.clone(), + cx, + ) + .into_any() + }, + move |cx| { + context_picker.update(cx, |this, cx| { + this.add_recent_file(project_path.clone(), cx); + }) + }, + ) + } + RecentEntry::Thread(thread) => { + let context_store = self.context_store.clone(); + let view_thread = thread.clone(); + + ContextMenuItem::custom_entry( + move |cx| { + render_thread_context_entry(&view_thread, context_store.clone(), cx) + .into_any() + }, + move |cx| { + context_picker.update(cx, |this, cx| { + this.add_recent_thread(thread.clone(), cx); + }) + }, + ) + } + } + } + + fn add_recent_file(&self, project_path: ProjectPath, cx: &mut ViewContext) { + let Some(context_store) = self.context_store.upgrade() else { + return; + }; + + let task = context_store.update(cx, |context_store, cx| { + context_store.add_file_from_path(project_path.clone(), cx) + }); + + let workspace = self.workspace.clone(); + + cx.spawn(|_, mut cx| async move { + match task.await { + Ok(_) => { + return anyhow::Ok(()); + } + Err(err) => { + let Some(workspace) = workspace.upgrade() else { + return anyhow::Ok(()); + }; + + workspace.update(&mut cx, |workspace, cx| { + workspace.show_error(&err, cx); + }) + } + } + }) + .detach_and_log_err(cx); + + cx.notify(); + } + + fn add_recent_thread(&self, thread: ThreadContextEntry, cx: &mut ViewContext) { + let Some(context_store) = self.context_store.upgrade() else { + return; + }; + + let Some(thread) = self + .thread_store + .clone() + .and_then(|this| this.upgrade()) + .and_then(|this| this.update(cx, |this, cx| this.open_thread(&thread.id, cx))) + else { + return; + }; + + context_store.update(cx, |context_store, cx| { + context_store.add_thread(thread, cx); + }); + + cx.notify(); + } + + fn recent_entries(&self, cx: &mut WindowContext) -> Vec { + let Some(workspace) = self.workspace.upgrade().map(|w| w.read(cx)) else { + return vec![]; + }; + + let Some(context_store) = self.context_store.upgrade().map(|cs| cs.read(cx)) else { + return vec![]; + }; + + let mut recent = Vec::with_capacity(6); + + let mut current_files = context_store.file_paths(cx); + + if let Some(active_path) = Self::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.thread_ids(); + + if let Some(active_thread) = workspace + .panel::(cx) + .map(|panel| panel.read(cx).active_thread(cx)) + { + current_threads.insert(active_thread.read(cx).id().clone()); + } + + let Some(thread_store) = self + .thread_store + .as_ref() + .and_then(|thread_store| thread_store.upgrade()) + else { + return recent; + }; + + thread_store.update(cx, |thread_store, cx| { + recent.extend( + thread_store + .threads(cx) + .into_iter() + .filter(|thread| !current_threads.contains(thread.read(cx).id())) + .take(2) + .map(|thread| { + let thread = thread.read(cx); + + RecentEntry::Thread(ThreadContextEntry { + id: thread.id().clone(), + summary: thread.summary_or_default(), + }) + }), + ) + }); + + recent + } + + fn active_singleton_buffer_path(workspace: &Workspace, cx: &AppContext) -> Option { + let active_item = workspace.active_item(cx)?; + + let editor = active_item.to_any().downcast::().ok()?.read(cx); + let buffer = editor.buffer().read(cx).as_singleton()?; + + let path = buffer.read(cx).file()?.path().to_path_buf(); + Some(path) } } @@ -105,7 +345,7 @@ impl EventEmitter for ContextPicker {} impl FocusableView for ContextPicker { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { match &self.mode { - ContextPickerMode::Default => self.picker.focus_handle(cx), + ContextPickerMode::Default(menu) => menu.focus_handle(cx), ContextPickerMode::File(file_picker) => file_picker.focus_handle(cx), ContextPickerMode::Directory(directory_picker) => directory_picker.focus_handle(cx), ContextPickerMode::Fetch(fetch_picker) => fetch_picker.focus_handle(cx), @@ -120,7 +360,7 @@ impl Render for ContextPicker { .w(px(400.)) .min_w(px(400.)) .map(|parent| match &self.mode { - ContextPickerMode::Default => parent.child(self.picker.clone()), + ContextPickerMode::Default(menu) => parent.child(menu.clone()), ContextPickerMode::File(file_picker) => parent.child(file_picker.clone()), ContextPickerMode::Directory(directory_picker) => { parent.child(directory_picker.clone()) @@ -130,140 +370,10 @@ impl Render for ContextPicker { }) } } - -#[derive(Clone)] -struct ContextPickerEntry { - name: SharedString, - kind: ContextKind, - icon: IconName, -} - -pub(crate) struct ContextPickerDelegate { - context_picker: WeakView, - workspace: WeakView, - thread_store: Option>, - context_store: WeakModel, - confirm_behavior: ConfirmBehavior, - entries: Vec, - selected_ix: usize, -} - -impl PickerDelegate for ContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.entries.len() - } - - fn selected_index(&self) -> usize { - self.selected_ix - } - - fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { - self.selected_ix = ix.min(self.entries.len().saturating_sub(1)); - cx.notify(); - } - - fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { - "Select a context source…".into() - } - - fn update_matches(&mut self, _query: String, _cx: &mut ViewContext>) -> Task<()> { - Task::ready(()) - } - - fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { - if let Some(entry) = self.entries.get(self.selected_ix) { - self.context_picker - .update(cx, |this, cx| { - match entry.kind { - ContextKind::File => { - this.mode = ContextPickerMode::File(cx.new_view(|cx| { - FileContextPicker::new( - self.context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - self.confirm_behavior, - cx, - ) - })); - } - ContextKind::Directory => { - this.mode = ContextPickerMode::Directory(cx.new_view(|cx| { - DirectoryContextPicker::new( - self.context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - self.confirm_behavior, - cx, - ) - })); - } - ContextKind::FetchedUrl => { - this.mode = ContextPickerMode::Fetch(cx.new_view(|cx| { - FetchContextPicker::new( - self.context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - self.confirm_behavior, - cx, - ) - })); - } - ContextKind::Thread => { - if let Some(thread_store) = self.thread_store.as_ref() { - this.mode = ContextPickerMode::Thread(cx.new_view(|cx| { - ThreadContextPicker::new( - thread_store.clone(), - self.context_picker.clone(), - self.context_store.clone(), - self.confirm_behavior, - cx, - ) - })); - } - } - } - - cx.focus_self(); - }) - .log_err(); - } - } - - fn dismissed(&mut self, cx: &mut ViewContext>) { - self.context_picker - .update(cx, |this, cx| match this.mode { - ContextPickerMode::Default => cx.emit(DismissEvent), - ContextPickerMode::File(_) - | ContextPickerMode::Directory(_) - | ContextPickerMode::Fetch(_) - | ContextPickerMode::Thread(_) => {} - }) - .log_err(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _cx: &mut ViewContext>, - ) -> Option { - let entry = &self.entries[ix]; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Dense) - .toggle_state(selected) - .child( - h_flex() - .min_w(px(250.)) - .max_w(px(400.)) - .gap_2() - .child(Icon::new(entry.icon).size(IconSize::Small)) - .child(Label::new(entry.name.clone()).single_line()), - ), - ) - } +enum RecentEntry { + File { + project_path: ProjectPath, + path_prefix: Arc, + }, + Thread(ThreadContextEntry), } diff --git a/crates/assistant2/src/context_picker/directory_context_picker.rs b/crates/assistant2/src/context_picker/directory_context_picker.rs index 2a6d0a7fa8..969e0e7f43 100644 --- a/crates/assistant2/src/context_picker/directory_context_picker.rs +++ b/crates/assistant2/src/context_picker/directory_context_picker.rs @@ -222,7 +222,7 @@ impl PickerDelegate for DirectoryContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker .update(cx, |this, cx| { - this.reset_mode(); + this.reset_mode(cx); cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/fetch_context_picker.rs b/crates/assistant2/src/context_picker/fetch_context_picker.rs index 3119f30fbe..545de80a3c 100644 --- a/crates/assistant2/src/context_picker/fetch_context_picker.rs +++ b/crates/assistant2/src/context_picker/fetch_context_picker.rs @@ -225,7 +225,7 @@ impl PickerDelegate for FetchContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker .update(cx, |this, cx| { - this.reset_mode(); + this.reset_mode(cx); cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/file_context_picker.rs b/crates/assistant2/src/context_picker/file_context_picker.rs index eac9f2ff74..282c4c70b9 100644 --- a/crates/assistant2/src/context_picker/file_context_picker.rs +++ b/crates/assistant2/src/context_picker/file_context_picker.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use file_icons::FileIcons; use fuzzy::PathMatch; -use gpui::{AppContext, DismissEvent, FocusHandle, FocusableView, Task, View, WeakModel, WeakView}; +use gpui::{ + AppContext, DismissEvent, FocusHandle, FocusableView, Stateful, Task, View, WeakModel, WeakView, +}; use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; use ui::{prelude::*, ListItem, Tooltip}; @@ -238,7 +240,7 @@ impl PickerDelegate for FileContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker .update(cx, |this, cx| { - this.reset_mode(); + this.reset_mode(cx); cx.emit(DismissEvent); }) .ok(); @@ -252,82 +254,97 @@ impl PickerDelegate for FileContextPickerDelegate { ) -> Option { let path_match = &self.matches[ix]; - let (file_name, directory) = if path_match.path.as_ref() == Path::new("") { - (SharedString::from(path_match.path_prefix.clone()), None) - } else { - let file_name = path_match - .path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - - let mut directory = format!("{}/", path_match.path_prefix); - if let Some(parent) = path_match - .path - .parent() - .filter(|parent| parent != &Path::new("")) - { - directory.push_str(&parent.to_string_lossy()); - directory.push('/'); - } - - (file_name, Some(directory)) - }; - - let added = self.context_store.upgrade().and_then(|context_store| { - context_store - .read(cx) - .will_include_file_path(&path_match.path, cx) - }); - - let file_icon = FileIcons::get_icon(&path_match.path.clone(), cx) - .map(Icon::from_path) - .unwrap_or_else(|| Icon::new(IconName::File)); - Some( ListItem::new(ix) .inset(true) .toggle_state(selected) - .child( - h_flex() - .gap_2() - .child(file_icon.size(IconSize::Small)) - .child(Label::new(file_name)) - .children(directory.map(|directory| { - Label::new(directory) - .size(LabelSize::Small) - .color(Color::Muted) - })), - ) - .when_some(added, |el, added| match added { - FileInclusion::Direct(_) => el.end_slot( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ), - FileInclusion::InDirectory(dir_name) => { - let dir_name = dir_name.to_string_lossy().into_owned(); - - el.end_slot( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Included").size(LabelSize::Small)), - ) - .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx)) - } - }), + .child(render_file_context_entry( + ElementId::NamedInteger("file-ctx-picker".into(), ix), + &path_match.path, + &path_match.path_prefix, + self.context_store.clone(), + cx, + )), ) } } + +pub fn render_file_context_entry( + id: ElementId, + path: &Path, + path_prefix: &Arc, + context_store: WeakModel, + cx: &WindowContext, +) -> Stateful
{ + let (file_name, directory) = if path == Path::new("") { + (SharedString::from(path_prefix.clone()), None) + } else { + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + .into(); + + let mut directory = format!("{}/", path_prefix); + + if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) { + directory.push_str(&parent.to_string_lossy()); + directory.push('/'); + } + + (file_name, Some(directory)) + }; + + let added = context_store + .upgrade() + .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx)); + + let file_icon = FileIcons::get_icon(&path, cx) + .map(Icon::from_path) + .unwrap_or_else(|| Icon::new(IconName::File)); + + h_flex() + .id(id) + .gap_1() + .w_full() + .child(file_icon.size(IconSize::Small)) + .child( + h_flex() + .gap_2() + .child(Label::new(file_name)) + .children(directory.map(|directory| { + Label::new(directory) + .size(LabelSize::Small) + .color(Color::Muted) + })), + ) + .child(div().w_full()) + .when_some(added, |el, added| match added { + FileInclusion::Direct(_) => el.child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .child(Label::new("Added").size(LabelSize::Small)), + ), + FileInclusion::InDirectory(dir_name) => { + let dir_name = dir_name.to_string_lossy().into_owned(); + + el.child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .child(Label::new("Included").size(LabelSize::Small)), + ) + .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx)) + } + }) +} diff --git a/crates/assistant2/src/context_picker/thread_context_picker.rs b/crates/assistant2/src/context_picker/thread_context_picker.rs index db09082bda..af52ec7172 100644 --- a/crates/assistant2/src/context_picker/thread_context_picker.rs +++ b/crates/assistant2/src/context_picker/thread_context_picker.rs @@ -6,7 +6,7 @@ use picker::{Picker, PickerDelegate}; use ui::{prelude::*, ListItem}; use crate::context_picker::{ConfirmBehavior, ContextPicker}; -use crate::context_store; +use crate::context_store::{self, ContextStore}; use crate::thread::ThreadId; use crate::thread_store::ThreadStore; @@ -47,9 +47,9 @@ impl Render for ThreadContextPicker { } #[derive(Debug, Clone)] -struct ThreadContextEntry { - id: ThreadId, - summary: SharedString, +pub struct ThreadContextEntry { + pub id: ThreadId, + pub summary: SharedString, } pub struct ThreadContextPickerDelegate { @@ -103,10 +103,8 @@ impl PickerDelegate for ThreadContextPickerDelegate { this.threads(cx) .into_iter() .map(|thread| { - const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread"); - let id = thread.read(cx).id().clone(); - let summary = thread.read(cx).summary().unwrap_or(DEFAULT_SUMMARY); + let summary = thread.read(cx).summary_or_default(); ThreadContextEntry { id, summary } }) .collect::>() @@ -179,7 +177,7 @@ impl PickerDelegate for ThreadContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker .update(cx, |this, cx| { - this.reset_mode(); + this.reset_mode(cx); cx.emit(DismissEvent); }) .ok(); @@ -193,27 +191,37 @@ impl PickerDelegate for ThreadContextPickerDelegate { ) -> Option { let thread = &self.matches[ix]; - let added = self.context_store.upgrade().map_or(false, |context_store| { - context_store.read(cx).includes_thread(&thread.id).is_some() - }); - - Some( - ListItem::new(ix) - .inset(true) - .toggle_state(selected) - .child(Label::new(thread.summary.clone())) - .when(added, |el| { - el.end_slot( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }), - ) + Some(ListItem::new(ix).inset(true).toggle_state(selected).child( + render_thread_context_entry(thread, self.context_store.clone(), cx), + )) } } + +pub fn render_thread_context_entry( + thread: &ThreadContextEntry, + context_store: WeakModel, + cx: &mut WindowContext, +) -> Div { + let added = context_store.upgrade().map_or(false, |ctx_store| { + ctx_store.read(cx).includes_thread(&thread.id).is_some() + }); + + h_flex() + .gap_1() + .w_full() + .child(Icon::new(IconName::MessageCircle).size(IconSize::Small)) + .child(Label::new(thread.summary.clone())) + .child(div().w_full()) + .when(added, |el| { + el.child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .child(Label::new("Added").size(LabelSize::Small)), + ) + }) +} diff --git a/crates/assistant2/src/context_store.rs b/crates/assistant2/src/context_store.rs index 2660a0e9bc..7c541da1c6 100644 --- a/crates/assistant2/src/context_store.rs +++ b/crates/assistant2/src/context_store.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, bail, Result}; -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, HashSet}; use futures::{self, future, Future, FutureExt}; use gpui::{AppContext, AsyncAppContext, Model, ModelContext, SharedString, Task, WeakView}; use language::Buffer; @@ -372,6 +372,23 @@ impl ContextStore { } } } + + pub fn file_paths(&self, cx: &AppContext) -> HashSet { + self.context + .iter() + .filter_map(|context| match context { + Context::File(file) => { + let buffer = file.context_buffer.buffer.read(cx); + buffer_path_log_err(buffer).map(|p| p.to_path_buf()) + } + Context::Directory(_) | Context::FetchedUrl(_) | Context::Thread(_) => None, + }) + .collect() + } + + pub fn thread_ids(&self) -> HashSet { + self.threads.keys().cloned().collect() + } } pub enum FileInclusion { diff --git a/crates/assistant2/src/context_strip.rs b/crates/assistant2/src/context_strip.rs index 42dc1244c7..0dd3a274c1 100644 --- a/crates/assistant2/src/context_strip.rs +++ b/crates/assistant2/src/context_strip.rs @@ -23,7 +23,7 @@ use crate::{AssistantPanel, RemoveAllContext, ToggleContextPicker}; pub struct ContextStrip { context_store: Model, - context_picker: View, + pub context_picker: View, context_picker_menu_handle: PopoverMenuHandle, focus_handle: FocusHandle, suggest_context_kind: SuggestContextKind, @@ -126,7 +126,7 @@ impl ContextStrip { } Some(SuggestedContext::Thread { - name: active_thread.summary().unwrap_or("New Thread".into()), + name: active_thread.summary_or_default(), thread: weak_active_thread, }) } @@ -168,7 +168,13 @@ impl Render for ContextStrip { .gap_1() .child( PopoverMenu::new("context-picker") - .menu(move |_cx| Some(context_picker.clone())) + .menu(move |cx| { + context_picker.update(cx, |this, cx| { + this.reset_mode(cx); + }); + + Some(context_picker.clone()) + }) .trigger( IconButton::new("add-context", IconName::Plus) .icon_size(IconSize::Small) diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index b2949680c4..c3933cf459 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -114,6 +114,11 @@ impl Thread { self.summary.clone() } + pub fn summary_or_default(&self) -> SharedString { + const DEFAULT: SharedString = SharedString::new_static("New Thread"); + self.summary.clone().unwrap_or(DEFAULT) + } + pub fn set_summary(&mut self, summary: impl Into, cx: &mut ModelContext) { self.summary = Some(summary.into()); cx.emit(ThreadEvent::SummaryChanged); diff --git a/crates/assistant2/src/thread_history.rs b/crates/assistant2/src/thread_history.rs index 95fa5ef548..0b33340d12 100644 --- a/crates/assistant2/src/thread_history.rs +++ b/crates/assistant2/src/thread_history.rs @@ -100,12 +100,8 @@ impl PastThread { impl RenderOnce for PastThread { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let (id, summary) = { - const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread"); let thread = self.thread.read(cx); - ( - thread.id().clone(), - thread.summary().unwrap_or(DEFAULT_SUMMARY), - ) + (thread.id().clone(), thread.summary_or_default()) }; let thread_timestamp = time_format::format_localized_timestamp( diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 52d28306ac..53f2a62bb3 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -12,19 +12,11 @@ use settings::Settings; use std::{rc::Rc, time::Duration}; use theme::ThemeSettings; -enum ContextMenuItem { +pub enum ContextMenuItem { Separator, Header(SharedString), Label(SharedString), - Entry { - toggle: Option<(IconPosition, bool)>, - label: SharedString, - icon: Option, - icon_size: IconSize, - handler: Rc, &mut WindowContext)>, - action: Option>, - disabled: bool, - }, + Entry(ContextMenuEntry), CustomEntry { entry_render: Box AnyElement>, handler: Rc, &mut WindowContext)>, @@ -32,6 +24,86 @@ enum ContextMenuItem { }, } +impl ContextMenuItem { + pub fn custom_entry( + entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static, + handler: impl Fn(&mut WindowContext) + 'static, + ) -> Self { + Self::CustomEntry { + entry_render: Box::new(entry_render), + handler: Rc::new(move |_, cx| handler(cx)), + selectable: true, + } + } +} + +pub struct ContextMenuEntry { + toggle: Option<(IconPosition, bool)>, + label: SharedString, + icon: Option, + icon_size: IconSize, + icon_position: IconPosition, + handler: Rc, &mut WindowContext)>, + action: Option>, + disabled: bool, +} + +impl ContextMenuEntry { + pub fn new(label: impl Into) -> Self { + ContextMenuEntry { + toggle: None, + label: label.into(), + icon: None, + icon_size: IconSize::Small, + icon_position: IconPosition::Start, + handler: Rc::new(|_, _| {}), + action: None, + disabled: false, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn icon_position(mut self, position: IconPosition) -> Self { + self.icon_position = position; + self + } + + pub fn icon_size(mut self, icon_size: IconSize) -> Self { + self.icon_size = icon_size; + self + } + + pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self { + self.toggle = Some((toggle_position, toggled)); + self + } + + pub fn action(mut self, action: Option>) -> Self { + self.action = action; + self + } + + pub fn handler(mut self, handler: impl Fn(&mut WindowContext) + 'static) -> Self { + self.handler = Rc::new(move |_, cx| handler(cx)); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl From for ContextMenuItem { + fn from(entry: ContextMenuEntry) -> Self { + ContextMenuItem::Entry(entry) + } +} + pub struct ContextMenu { items: Vec, focus_handle: FocusHandle, @@ -93,21 +165,32 @@ impl ContextMenu { self } + pub fn extend>(mut self, items: impl IntoIterator) -> Self { + self.items.extend(items.into_iter().map(Into::into)); + self + } + + pub fn item(mut self, item: impl Into) -> Self { + self.items.push(item.into()); + self + } + pub fn entry( mut self, label: impl Into, action: Option>, handler: impl Fn(&mut WindowContext) + 'static, ) -> Self { - self.items.push(ContextMenuItem::Entry { + self.items.push(ContextMenuItem::Entry(ContextMenuEntry { toggle: None, label: label.into(), handler: Rc::new(move |_, cx| handler(cx)), icon: None, icon_size: IconSize::Small, + icon_position: IconPosition::End, action, disabled: false, - }); + })); self } @@ -119,15 +202,16 @@ impl ContextMenu { action: Option>, handler: impl Fn(&mut WindowContext) + 'static, ) -> Self { - self.items.push(ContextMenuItem::Entry { + self.items.push(ContextMenuItem::Entry(ContextMenuEntry { toggle: Some((position, toggled)), label: label.into(), handler: Rc::new(move |_, cx| handler(cx)), icon: None, icon_size: IconSize::Small, + icon_position: position, action, disabled: false, - }); + })); self } @@ -162,7 +246,7 @@ impl ContextMenu { } pub fn action(mut self, label: impl Into, action: Box) -> Self { - self.items.push(ContextMenuItem::Entry { + self.items.push(ContextMenuItem::Entry(ContextMenuEntry { toggle: None, label: label.into(), action: Some(action.boxed_clone()), @@ -174,9 +258,10 @@ impl ContextMenu { cx.dispatch_action(action.boxed_clone()); }), icon: None, + icon_position: IconPosition::End, icon_size: IconSize::Small, disabled: false, - }); + })); self } @@ -185,7 +270,7 @@ impl ContextMenu { label: impl Into, action: Box, ) -> Self { - self.items.push(ContextMenuItem::Entry { + self.items.push(ContextMenuItem::Entry(ContextMenuEntry { toggle: None, label: label.into(), action: Some(action.boxed_clone()), @@ -198,13 +283,14 @@ impl ContextMenu { }), icon: None, icon_size: IconSize::Small, + icon_position: IconPosition::End, disabled: true, - }); + })); self } pub fn link(mut self, label: impl Into, action: Box) -> Self { - self.items.push(ContextMenuItem::Entry { + self.items.push(ContextMenuItem::Entry(ContextMenuEntry { toggle: None, label: label.into(), @@ -212,19 +298,20 @@ impl ContextMenu { handler: Rc::new(move |_, cx| cx.dispatch_action(action.boxed_clone())), icon: Some(IconName::ArrowUpRight), icon_size: IconSize::XSmall, + icon_position: IconPosition::End, disabled: false, - }); + })); self } pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { let context = self.action_context.as_ref(); if let Some( - ContextMenuItem::Entry { + ContextMenuItem::Entry(ContextMenuEntry { handler, disabled: false, .. - } + }) | ContextMenuItem::CustomEntry { handler, .. }, ) = self.selected_index.and_then(|ix| self.items.get(ix)) { @@ -304,11 +391,11 @@ impl ContextMenu { } if let Some(ix) = self.items.iter().position(|item| { - if let ContextMenuItem::Entry { + if let ContextMenuItem::Entry(ContextMenuEntry { action: Some(action), disabled: false, .. - } = item + }) = item { action.partial_eq(dispatched) } else { @@ -346,7 +433,7 @@ impl ContextMenuItem { ContextMenuItem::Header(_) | ContextMenuItem::Separator | ContextMenuItem::Label { .. } => false, - ContextMenuItem::Entry { disabled, .. } => !disabled, + ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled, ContextMenuItem::CustomEntry { selectable, .. } => *selectable, } } @@ -356,12 +443,17 @@ impl Render for ContextMenu { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let ui_font_size = ThemeSettings::get_global(cx).ui_font_size; - div().occlude().elevation_2(cx).flex().flex_row().child( - WithRemSize::new(ui_font_size).flex().child( + WithRemSize::new(ui_font_size) + .occlude() + .elevation_2(cx) + .flex() + .flex_row() + .child( v_flex() .id("context-menu") .min_w(px(200.)) .max_h(vh(0.75, cx)) + .flex_1() .overflow_y_scroll() .track_focus(&self.focus_handle(cx)) .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx))) @@ -374,11 +466,11 @@ impl Render for ContextMenu { .on_action(cx.listener(ContextMenu::cancel)) .when(!self.delayed, |mut el| { for item in self.items.iter() { - if let ContextMenuItem::Entry { + if let ContextMenuItem::Entry(ContextMenuEntry { action: Some(action), disabled: false, .. - } = item + }) = item { el = el.on_boxed_action( &**action, @@ -388,7 +480,6 @@ impl Render for ContextMenu { } el }) - .flex_none() .child(List::new().children(self.items.iter_mut().enumerate().map( |(ix, item)| { match item { @@ -403,15 +494,16 @@ impl Render for ContextMenu { .disabled(true) .child(Label::new(label.clone())) .into_any_element(), - ContextMenuItem::Entry { + ContextMenuItem::Entry(ContextMenuEntry { toggle, label, handler, icon, icon_size, + icon_position, action, disabled, - } => { + }) => { let handler = handler.clone(); let menu = cx.view().downgrade(); let color = if *disabled { @@ -422,10 +514,21 @@ impl Render for ContextMenu { let label_element = if let Some(icon_name) = icon { h_flex() .gap_1() + .when(*icon_position == IconPosition::Start, |flex| { + flex.child( + Icon::new(*icon_name) + .size(*icon_size) + .color(color), + ) + }) .child(Label::new(label.clone()).color(color)) - .child( - Icon::new(*icon_name).size(*icon_size).color(color), - ) + .when(*icon_position == IconPosition::End, |flex| { + flex.child( + Icon::new(*icon_name) + .size(*icon_size) + .color(color), + ) + }) .into_any_element() } else { Label::new(label.clone()).color(color).into_any_element() @@ -520,7 +623,6 @@ impl Render for ContextMenu { } }, ))), - ), - ) + ) } } diff --git a/crates/ui/src/utils/with_rem_size.rs b/crates/ui/src/utils/with_rem_size.rs index 9011d1e28e..dabeb08fe9 100644 --- a/crates/ui/src/utils/with_rem_size.rs +++ b/crates/ui/src/utils/with_rem_size.rs @@ -1,6 +1,7 @@ use gpui::{ div, AnyElement, Bounds, Div, DivFrameState, Element, ElementId, GlobalElementId, Hitbox, - IntoElement, LayoutId, ParentElement, Pixels, StyleRefinement, Styled, WindowContext, + InteractiveElement as _, IntoElement, LayoutId, ParentElement, Pixels, StyleRefinement, Styled, + WindowContext, }; /// An element that sets a particular rem size for its children. @@ -18,6 +19,13 @@ impl WithRemSize { rem_size: rem_size.into(), } } + + /// Block the mouse from interacting with this element or any of its children + /// The fluent API equivalent to [`Interactivity::occlude_mouse`] + pub fn occlude(mut self) -> Self { + self.div = self.div.occlude(); + self + } } impl Styled for WithRemSize { @@ -37,7 +45,7 @@ impl Element for WithRemSize { type PrepaintState = Option; fn id(&self) -> Option { - self.div.id() + Element::id(&self.div) } fn request_layout( diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4a11662705..ad91bb386b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1316,11 +1316,10 @@ impl Workspace { &self.project } - pub fn recent_navigation_history( + pub fn recent_navigation_history_iter( &self, - limit: Option, cx: &AppContext, - ) -> Vec<(ProjectPath, Option)> { + ) -> impl Iterator)> { let mut abs_paths_opened: HashMap> = HashMap::default(); let mut history: HashMap, usize)> = HashMap::default(); for pane in &self.panes { @@ -1353,7 +1352,7 @@ impl Workspace { .sorted_by_key(|(_, (_, timestamp))| *timestamp) .map(|(project_path, (fs_path, _))| (project_path, fs_path)) .rev() - .filter(|(history_path, abs_path)| { + .filter(move |(history_path, abs_path)| { let latest_project_path_opened = abs_path .as_ref() .and_then(|abs_path| abs_paths_opened.get(abs_path)) @@ -1368,6 +1367,14 @@ impl Workspace { None => true, } }) + } + + pub fn recent_navigation_history( + &self, + limit: Option, + cx: &AppContext, + ) -> Vec<(ProjectPath, Option)> { + self.recent_navigation_history_iter(cx) .take(limit.unwrap_or(usize::MAX)) .collect() }