use std::fmt::Display;use std::ops::Range;use std::sync::Arc; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};use editor::{Editor, EditorEvent};use fuzzy::{StringMatch, StringMatchCandidate};use gpui::{ App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list,};use time::{OffsetDateTime, UtcOffset};use ui::{ HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, prelude::*,};use util::ResultExt; use crate::history_store::{HistoryEntry, HistoryStore};use crate::{AgentPanel, RemoveSelectedThread}; pub struct ThreadHistory { agent_panel: WeakEntity<AgentPanel>, history_store: Entity<HistoryStore>, scroll_handle: UniformListScrollHandle, selected_index: usize, hovered_index: Option<usize>, search_editor: Entity<Editor>, all_entries: Arc<Vec<HistoryEntry>>, // When the search is empty, we display date separators between history entries // This vector contains an enum of either a separator or an actual entry separated_items: Vec<ListItemType>, // Maps entry indexes to list item indexes separated_item_indexes: Vec<u32>, _separated_items_task: Option<Task<()>>, search_state: SearchState, scrollbar_visibility: bool, scrollbar_state: ScrollbarState, _subscriptions: Vec<gpui::Subscription>,} enum SearchState { Empty, Searching { query: SharedString, _task: Task<()>, }, Searched { query: SharedString, matches: Vec<StringMatch>, },} enum ListItemType { BucketSeparator(TimeBucket), Entry { index: usize, format: EntryTimeFormat, },} impl ListItemType { fn entry_index(&self) -> Option<usize> { match self { ListItemType::BucketSeparator(_) => None, ListItemType::Entry { index, .. } => Some(*index), } }} impl ThreadHistory { pub(crate) fn new( agent_panel: WeakEntity<AgentPanel>, history_store: Entity<HistoryStore>, window: &mut Window, cx: &mut Context<Self>, ) -> Self { let search_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Search threads...", cx); editor }); let search_editor_subscription = cx.subscribe(&search_editor, |this, search_editor, event, cx| { if let EditorEvent::BufferEdited = event { let query = search_editor.read(cx).text(cx); this.search(query.into(), cx); } }); let history_store_subscription = cx.observe(&history_store, |this, _, cx| { this.update_all_entries(cx); }); let scroll_handle = UniformListScrollHandle::default(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let mut this = Self { agent_panel, history_store, scroll_handle, selected_index: 0, hovered_index: None, search_state: SearchState::Empty, all_entries: Default::default(), separated_items: Default::default(), separated_item_indexes: Default::default(), search_editor, scrollbar_visibility: true, scrollbar_state, _subscriptions: vec![search_editor_subscription, history_store_subscription], _separated_items_task: None, }; this.update_all_entries(cx); this } fn update_all_entries(&mut self, cx: &mut Context<Self>) { let new_entries: Arc<Vec<HistoryEntry>> = self .history_store .update(cx, |store, cx| store.entries(cx)) .into(); self._separated_items_task.take(); let mut items = Vec::with_capacity(new_entries.len() + 1); let mut indexes = Vec::with_capacity(new_entries.len() + 1); let bg_task = cx.background_spawn(async move { let mut bucket = None; let today = Local::now().naive_local().date(); for (index, entry) in new_entries.iter().enumerate() { let entry_date = entry .updated_at() .with_timezone(&Local) .naive_local() .date(); let entry_bucket = TimeBucket::from_dates(today, entry_date); if Some(entry_bucket) != bucket { bucket = Some(entry_bucket); items.push(ListItemType::BucketSeparator(entry_bucket)); } indexes.push(items.len() as u32); items.push(ListItemType::Entry { index, format: entry_bucket.into(), }); } (new_entries, items, indexes) }); let task = cx.spawn(async move |this, cx| { let (new_entries, items, indexes) = bg_task.await; this.update(cx, |this, cx| { let previously_selected_entry = this.all_entries.get(this.selected_index).map(|e| e.id()); this.all_entries = new_entries; this.separated_items = items; this.separated_item_indexes = indexes; match &this.search_state { SearchState::Empty => { if this.selected_index >= this.all_entries.len() { this.set_selected_entry_index( this.all_entries.len().saturating_sub(1), cx, ); } else if let Some(prev_id) = previously_selected_entry { if let Some(new_ix) = this .all_entries .iter() .position(|probe| probe.id() == prev_id) { this.set_selected_entry_index(new_ix, cx); } } } SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => { this.search(query.clone(), cx); } } cx.notify(); }) .log_err(); }); self._separated_items_task = Some(task); } fn search(&mut self, query: SharedString, cx: &mut Context<Self>) { if query.is_empty() { self.search_state = SearchState::Empty; cx.notify(); return; } let all_entries = self.all_entries.clone(); let fuzzy_search_task = cx.background_spawn({ let query = query.clone(); let executor = cx.background_executor().clone(); async move { let mut candidates = Vec::with_capacity(all_entries.len()); for (idx, entry) in all_entries.iter().enumerate() { match entry { HistoryEntry::Thread(thread) => { candidates.push(StringMatchCandidate::new(idx, &thread.summary)); } HistoryEntry::Context(context) => { candidates.push(StringMatchCandidate::new(idx, &context.title)); } } } const MAX_MATCHES: usize = 100; fuzzy::match_strings( &candidates, &query, false, MAX_MATCHES, &Default::default(), executor, ) .await } }); let task = cx.spawn({ let query = query.clone(); async move |this, cx| { let matches = fuzzy_search_task.await; this.update(cx, |this, cx| { let SearchState::Searching { query: current_query, _task, } = &this.search_state else { return; }; if &query == current_query { this.search_state = SearchState::Searched { query: query.clone(), matches, }; this.set_selected_entry_index(0, cx); cx.notify(); }; }) .log_err(); } }); self.search_state = SearchState::Searching { query, _task: task }; cx.notify(); } fn matched_count(&self) -> usize { match &self.search_state { SearchState::Empty => self.all_entries.len(), SearchState::Searching { .. } => 0, SearchState::Searched { matches, .. } => matches.len(), } } fn list_item_count(&self) -> usize { match &self.search_state { SearchState::Empty => self.separated_items.len(), SearchState::Searching { .. } => 0, SearchState::Searched { matches, .. } => matches.len(), } } fn search_produced_no_matches(&self) -> bool { match &self.search_state { SearchState::Empty => false, SearchState::Searching { .. } => false, SearchState::Searched { matches, .. } => matches.is_empty(), } } fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { match &self.search_state { SearchState::Empty => self.all_entries.get(ix), SearchState::Searching { .. } => None, SearchState::Searched { matches, .. } => matches .get(ix) .and_then(|m| self.all_entries.get(m.candidate_id)), } } pub fn select_previous( &mut self, _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context<Self>, ) { let count = self.matched_count(); if count > 0 { if self.selected_index == 0 { self.set_selected_entry_index(count - 1, cx); } else { self.set_selected_entry_index(self.selected_index - 1, cx); } } } pub fn select_next( &mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>, ) { let count = self.matched_count(); if count > 0 { if self.selected_index == count - 1 { self.set_selected_entry_index(0, cx); } else { self.set_selected_entry_index(self.selected_index + 1, cx); } } } fn select_first( &mut self, _: &menu::SelectFirst, _window: &mut Window, cx: &mut Context<Self>, ) { let count = self.matched_count(); if count > 0 { self.set_selected_entry_index(0, cx); } } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) { let count = self.matched_count(); if count > 0 { self.set_selected_entry_index(count - 1, cx); } } fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context<Self>) { self.selected_index = entry_index; let scroll_ix = match self.search_state { SearchState::Empty | SearchState::Searching { .. } => self .separated_item_indexes .get(entry_index) .map(|ix| *ix as usize) .unwrap_or(entry_index + 1), SearchState::Searched { .. } => entry_index, }; self.scroll_handle .scroll_to_item(scroll_ix, ScrollStrategy::Top); cx.notify(); } fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> { if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) { return None; } Some( div() .occlude() .id("thread-history-scroll") .h_full() .bg(cx.theme().colors().panel_background.opacity(0.8)) .border_l_1() .border_color(cx.theme().colors().border_variant) .absolute() .right_1() .top_0() .bottom_0() .w_4() .pl_1() .cursor_default() .on_mouse_move(cx.listener(|_, _, _window, cx| { cx.notify(); cx.stop_propagation() })) .on_hover(|_, _window, cx| { cx.stop_propagation(); }) .on_any_mouse_down(|_, _window, cx| { cx.stop_propagation(); }) .on_scroll_wheel(cx.listener(|_, _, _window, cx| { cx.notify(); })) .children(Scrollbar::vertical(self.scrollbar_state.clone())), ) } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { HistoryEntry::Thread(thread) => self.agent_panel.update(cx, move |this, cx| { this.open_thread_by_id(&thread.id, window, cx) }), HistoryEntry::Context(context) => self.agent_panel.update(cx, move |this, cx| { this.open_saved_prompt_editor(context.path.clone(), window, cx) }), }; if let Some(task) = task_result.log_err() { task.detach_and_log_err(cx); }; cx.notify(); } } fn remove_selected_thread( &mut self, _: &RemoveSelectedThread, _window: &mut Window, cx: &mut Context<Self>, ) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { HistoryEntry::Thread(thread) => self .agent_panel .update(cx, |this, cx| this.delete_thread(&thread.id, cx)), HistoryEntry::Context(context) => self .agent_panel .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)), }; if let Some(task) = task_result.log_err() { task.detach_and_log_err(cx); }; cx.notify(); } } fn list_items( &mut self, range: Range<usize>, _window: &mut Window, cx: &mut Context<Self>, ) -> Vec<AnyElement> { let range_start = range.start; match &self.search_state { SearchState::Empty => self .separated_items .get(range) .iter() .flat_map(|items| { items .iter() .map(|item| self.render_list_item(item.entry_index(), item, vec![], cx)) }) .collect(), SearchState::Searched { matches, .. } => matches[range] .iter() .enumerate() .map(|(ix, m)| { self.render_list_item( Some(range_start + ix), &ListItemType::Entry { index: m.candidate_id, format: EntryTimeFormat::DateAndTime, }, m.positions.clone(), cx, ) }) .collect(), SearchState::Searching { .. } => { vec![] } } } fn render_list_item( &self, list_entry_ix: Option<usize>, item: &ListItemType, highlight_positions: Vec<usize>, cx: &Context<Self>, ) -> AnyElement { match item { ListItemType::Entry { index, format } => match self.all_entries.get(*index) { Some(entry) => h_flex() .w_full()Lines 1–500 of 914. Load more
use std::fmt::Display; use std::ops::Range; use std::sync::Arc; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; use editor::{Editor, EditorEvent}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list, }; use time::{OffsetDateTime, UtcOffset}; use ui::{ HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, prelude::*, }; use util::ResultExt; use crate::history_store::{HistoryEntry, HistoryStore}; use crate::{AgentPanel, RemoveSelectedThread}; pub struct ThreadHistory { agent_panel: WeakEntity<AgentPanel>, history_store: Entity<HistoryStore>, scroll_handle: UniformListScrollHandle, selected_index: usize, hovered_index: Option<usize>, search_editor: Entity<Editor>, all_entries: Arc<Vec<HistoryEntry>>, // When the search is empty, we display date separators between history entries // This vector contains an enum of either a separator or an actual entry separated_items: Vec<ListItemType>, // Maps entry indexes to list item indexes separated_item_indexes: Vec<u32>, _separated_items_task: Option<Task<()>>, search_state: SearchState, scrollbar_visibility: bool, scrollbar_state: ScrollbarState, _subscriptions: Vec<gpui::Subscription>, } enum SearchState { Empty, Searching { query: SharedString, _task: Task<()>, }, Searched { query: SharedString, matches: Vec<StringMatch>, }, } enum ListItemType { BucketSeparator(TimeBucket), Entry { index: usize, format: EntryTimeFormat, }, } impl ListItemType { fn entry_index(&self) -> Option<usize> { match self { ListItemType::BucketSeparator(_) => None, ListItemType::Entry { index, .. } => Some(*index), } } } impl ThreadHistory { pub(crate) fn new( agent_panel: WeakEntity<AgentPanel>, history_store: Entity<HistoryStore>, window: &mut Window, cx: &mut Context<Self>, ) -> Self { let search_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Search threads...", cx); editor }); let search_editor_subscription = cx.subscribe(&search_editor, |this, search_editor, event, cx| { if let EditorEvent::BufferEdited = event { let query = search_editor.read(cx).text(cx); this.search(query.into(), cx); } }); let history_store_subscription = cx.observe(&history_store, |this, _, cx| { this.update_all_entries(cx); }); let scroll_handle = UniformListScrollHandle::default(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let mut this = Self { agent_panel, history_store, scroll_handle, selected_index: 0, hovered_index: None, search_state: SearchState::Empty, all_entries: Default::default(), separated_items: Default::default(), separated_item_indexes: Default::default(), search_editor, scrollbar_visibility: true, scrollbar_state, _subscriptions: vec![search_editor_subscription, history_store_subscription], _separated_items_task: None, }; this.update_all_entries(cx); this } fn update_all_entries(&mut self, cx: &mut Context<Self>) { let new_entries: Arc<Vec<HistoryEntry>> = self .history_store .update(cx, |store, cx| store.entries(cx)) .into(); self._separated_items_task.take(); let mut items = Vec::with_capacity(new_entries.len() + 1); let mut indexes = Vec::with_capacity(new_entries.len() + 1); let bg_task = cx.background_spawn(async move { let mut bucket = None; let today = Local::now().naive_local().date(); for (index, entry) in new_entries.iter().enumerate() { let entry_date = entry .updated_at() .with_timezone(&Local) .naive_local() .date(); let entry_bucket = TimeBucket::from_dates(today, entry_date); if Some(entry_bucket) != bucket { bucket = Some(entry_bucket); items.push(ListItemType::BucketSeparator(entry_bucket)); } indexes.push(items.len() as u32); items.push(ListItemType::Entry { index, format: entry_bucket.into(), }); } (new_entries, items, indexes) }); let task = cx.spawn(async move |this, cx| { let (new_entries, items, indexes) = bg_task.await; this.update(cx, |this, cx| { let previously_selected_entry = this.all_entries.get(this.selected_index).map(|e| e.id()); this.all_entries = new_entries; this.separated_items = items; this.separated_item_indexes = indexes; match &this.search_state { SearchState::Empty => { if this.selected_index >= this.all_entries.len() { this.set_selected_entry_index( this.all_entries.len().saturating_sub(1), cx, ); } else if let Some(prev_id) = previously_selected_entry { if let Some(new_ix) = this .all_entries .iter() .position(|probe| probe.id() == prev_id) { this.set_selected_entry_index(new_ix, cx); } } } SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => { this.search(query.clone(), cx); } } cx.notify(); }) .log_err(); }); self._separated_items_task = Some(task); } fn search(&mut self, query: SharedString, cx: &mut Context<Self>) { if query.is_empty() { self.search_state = SearchState::Empty; cx.notify(); return; } let all_entries = self.all_entries.clone(); let fuzzy_search_task = cx.background_spawn({ let query = query.clone(); let executor = cx.background_executor().clone(); async move { let mut candidates = Vec::with_capacity(all_entries.len()); for (idx, entry) in all_entries.iter().enumerate() { match entry { HistoryEntry::Thread(thread) => { candidates.push(StringMatchCandidate::new(idx, &thread.summary)); } HistoryEntry::Context(context) => { candidates.push(StringMatchCandidate::new(idx, &context.title)); } } } const MAX_MATCHES: usize = 100; fuzzy::match_strings( &candidates, &query, false, MAX_MATCHES, &Default::default(), executor, ) .await } }); let task = cx.spawn({ let query = query.clone(); async move |this, cx| { let matches = fuzzy_search_task.await; this.update(cx, |this, cx| { let SearchState::Searching { query: current_query, _task, } = &this.search_state else { return; }; if &query == current_query { this.search_state = SearchState::Searched { query: query.clone(), matches, }; this.set_selected_entry_index(0, cx); cx.notify(); }; }) .log_err(); } }); self.search_state = SearchState::Searching { query, _task: task }; cx.notify(); } fn matched_count(&self) -> usize { match &self.search_state { SearchState::Empty => self.all_entries.len(), SearchState::Searching { .. } => 0, SearchState::Searched { matches, .. } => matches.len(), } } fn list_item_count(&self) -> usize { match &self.search_state { SearchState::Empty => self.separated_items.len(), SearchState::Searching { .. } => 0, SearchState::Searched { matches, .. } => matches.len(), } } fn search_produced_no_matches(&self) -> bool { match &self.search_state { SearchState::Empty => false, SearchState::Searching { .. } => false, SearchState::Searched { matches, .. } => matches.is_empty(), } } fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { match &self.search_state { SearchState::Empty => self.all_entries.get(ix), SearchState::Searching { .. } => None, SearchState::Searched { matches, .. } => matches .get(ix) .and_then(|m| self.all_entries.get(m.candidate_id)), } } pub fn select_previous( &mut self, _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context<Self>, ) { let count = self.matched_count(); if count > 0 { if self.selected_index == 0 { self.set_selected_entry_index(count - 1, cx); } else { self.set_selected_entry_index(self.selected_index - 1, cx); } } } pub fn select_next( &mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>, ) { let count = self.matched_count(); if count > 0 { if self.selected_index == count - 1 { self.set_selected_entry_index(0, cx); } else { self.set_selected_entry_index(self.selected_index + 1, cx); } } } fn select_first( &mut self, _: &menu::SelectFirst, _window: &mut Window, cx: &mut Context<Self>, ) { let count = self.matched_count(); if count > 0 { self.set_selected_entry_index(0, cx); } } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) { let count = self.matched_count(); if count > 0 { self.set_selected_entry_index(count - 1, cx); } } fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context<Self>) { self.selected_index = entry_index; let scroll_ix = match self.search_state { SearchState::Empty | SearchState::Searching { .. } => self .separated_item_indexes .get(entry_index) .map(|ix| *ix as usize) .unwrap_or(entry_index + 1), SearchState::Searched { .. } => entry_index, }; self.scroll_handle .scroll_to_item(scroll_ix, ScrollStrategy::Top); cx.notify(); } fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> { if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) { return None; } Some( div() .occlude() .id("thread-history-scroll") .h_full() .bg(cx.theme().colors().panel_background.opacity(0.8)) .border_l_1() .border_color(cx.theme().colors().border_variant) .absolute() .right_1() .top_0() .bottom_0() .w_4() .pl_1() .cursor_default() .on_mouse_move(cx.listener(|_, _, _window, cx| { cx.notify(); cx.stop_propagation() })) .on_hover(|_, _window, cx| { cx.stop_propagation(); }) .on_any_mouse_down(|_, _window, cx| { cx.stop_propagation(); }) .on_scroll_wheel(cx.listener(|_, _, _window, cx| { cx.notify(); })) .children(Scrollbar::vertical(self.scrollbar_state.clone())), ) } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { HistoryEntry::Thread(thread) => self.agent_panel.update(cx, move |this, cx| { this.open_thread_by_id(&thread.id, window, cx) }), HistoryEntry::Context(context) => self.agent_panel.update(cx, move |this, cx| { this.open_saved_prompt_editor(context.path.clone(), window, cx) }), }; if let Some(task) = task_result.log_err() { task.detach_and_log_err(cx); }; cx.notify(); } } fn remove_selected_thread( &mut self, _: &RemoveSelectedThread, _window: &mut Window, cx: &mut Context<Self>, ) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { HistoryEntry::Thread(thread) => self .agent_panel .update(cx, |this, cx| this.delete_thread(&thread.id, cx)), HistoryEntry::Context(context) => self .agent_panel .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)), }; if let Some(task) = task_result.log_err() { task.detach_and_log_err(cx); }; cx.notify(); } } fn list_items( &mut self, range: Range<usize>, _window: &mut Window, cx: &mut Context<Self>, ) -> Vec<AnyElement> { let range_start = range.start; match &self.search_state { SearchState::Empty => self .separated_items .get(range) .iter() .flat_map(|items| { items .iter() .map(|item| self.render_list_item(item.entry_index(), item, vec![], cx)) }) .collect(), SearchState::Searched { matches, .. } => matches[range] .iter() .enumerate() .map(|(ix, m)| { self.render_list_item( Some(range_start + ix), &ListItemType::Entry { index: m.candidate_id, format: EntryTimeFormat::DateAndTime, }, m.positions.clone(), cx, ) }) .collect(), SearchState::Searching { .. } => { vec![] } } } fn render_list_item( &self, list_entry_ix: Option<usize>, item: &ListItemType, highlight_positions: Vec<usize>, cx: &Context<Self>, ) -> AnyElement { match item { ListItemType::Entry { index, format } => match self.all_entries.get(*index) { Some(entry) => h_flex() .w_full()