thread_history.rs

  1use crate::RemoveSelectedThread;
  2use agent2::{HistoryEntry, HistoryStore};
  3use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
  4use editor::{Editor, EditorEvent};
  5use fuzzy::{StringMatch, StringMatchCandidate};
  6use gpui::{
  7    App, Empty, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
  8    UniformListScrollHandle, Window, uniform_list,
  9};
 10use std::{fmt::Display, ops::Range, sync::Arc};
 11use time::{OffsetDateTime, UtcOffset};
 12use ui::{
 13    HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
 14    Tooltip, prelude::*,
 15};
 16use util::ResultExt;
 17
 18pub struct AcpThreadHistory {
 19    pub(crate) history_store: Entity<HistoryStore>,
 20    scroll_handle: UniformListScrollHandle,
 21    selected_index: usize,
 22    hovered_index: Option<usize>,
 23    search_editor: Entity<Editor>,
 24    all_entries: Arc<Vec<HistoryEntry>>,
 25    // When the search is empty, we display date separators between history entries
 26    // This vector contains an enum of either a separator or an actual entry
 27    separated_items: Vec<ListItemType>,
 28    // Maps entry indexes to list item indexes
 29    separated_item_indexes: Vec<u32>,
 30    _separated_items_task: Option<Task<()>>,
 31    search_state: SearchState,
 32    scrollbar_visibility: bool,
 33    scrollbar_state: ScrollbarState,
 34    local_timezone: UtcOffset,
 35    _subscriptions: Vec<gpui::Subscription>,
 36}
 37
 38enum SearchState {
 39    Empty,
 40    Searching {
 41        query: SharedString,
 42        _task: Task<()>,
 43    },
 44    Searched {
 45        query: SharedString,
 46        matches: Vec<StringMatch>,
 47    },
 48}
 49
 50enum ListItemType {
 51    BucketSeparator(TimeBucket),
 52    Entry {
 53        index: usize,
 54        format: EntryTimeFormat,
 55    },
 56}
 57
 58pub enum ThreadHistoryEvent {
 59    Open(HistoryEntry),
 60}
 61
 62impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
 63
 64impl AcpThreadHistory {
 65    pub(crate) fn new(
 66        history_store: Entity<agent2::HistoryStore>,
 67        window: &mut Window,
 68        cx: &mut Context<Self>,
 69    ) -> Self {
 70        let search_editor = cx.new(|cx| {
 71            let mut editor = Editor::single_line(window, cx);
 72            editor.set_placeholder_text("Search threads...", cx);
 73            editor
 74        });
 75
 76        let search_editor_subscription =
 77            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
 78                if let EditorEvent::BufferEdited = event {
 79                    let query = search_editor.read(cx).text(cx);
 80                    this.search(query.into(), cx);
 81                }
 82            });
 83
 84        let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
 85            this.update_all_entries(cx);
 86        });
 87
 88        let scroll_handle = UniformListScrollHandle::default();
 89        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
 90
 91        let mut this = Self {
 92            history_store,
 93            scroll_handle,
 94            selected_index: 0,
 95            hovered_index: None,
 96            search_state: SearchState::Empty,
 97            all_entries: Default::default(),
 98            separated_items: Default::default(),
 99            separated_item_indexes: Default::default(),
100            search_editor,
101            scrollbar_visibility: true,
102            scrollbar_state,
103            local_timezone: UtcOffset::from_whole_seconds(
104                chrono::Local::now().offset().local_minus_utc(),
105            )
106            .unwrap(),
107            _subscriptions: vec![search_editor_subscription, history_store_subscription],
108            _separated_items_task: None,
109        };
110        this.update_all_entries(cx);
111        this
112    }
113
114    fn update_all_entries(&mut self, cx: &mut Context<Self>) {
115        let new_entries: Arc<Vec<HistoryEntry>> = self
116            .history_store
117            .update(cx, |store, cx| store.entries(cx))
118            .into();
119
120        self._separated_items_task.take();
121
122        let mut items = Vec::with_capacity(new_entries.len() + 1);
123        let mut indexes = Vec::with_capacity(new_entries.len() + 1);
124
125        let bg_task = cx.background_spawn(async move {
126            let mut bucket = None;
127            let today = Local::now().naive_local().date();
128
129            for (index, entry) in new_entries.iter().enumerate() {
130                let entry_date = entry
131                    .updated_at()
132                    .with_timezone(&Local)
133                    .naive_local()
134                    .date();
135                let entry_bucket = TimeBucket::from_dates(today, entry_date);
136
137                if Some(entry_bucket) != bucket {
138                    bucket = Some(entry_bucket);
139                    items.push(ListItemType::BucketSeparator(entry_bucket));
140                }
141
142                indexes.push(items.len() as u32);
143                items.push(ListItemType::Entry {
144                    index,
145                    format: entry_bucket.into(),
146                });
147            }
148            (new_entries, items, indexes)
149        });
150
151        let task = cx.spawn(async move |this, cx| {
152            let (new_entries, items, indexes) = bg_task.await;
153            this.update(cx, |this, cx| {
154                let previously_selected_entry =
155                    this.all_entries.get(this.selected_index).map(|e| e.id());
156
157                this.all_entries = new_entries;
158                this.separated_items = items;
159                this.separated_item_indexes = indexes;
160
161                match &this.search_state {
162                    SearchState::Empty => {
163                        if this.selected_index >= this.all_entries.len() {
164                            this.set_selected_entry_index(
165                                this.all_entries.len().saturating_sub(1),
166                                cx,
167                            );
168                        } else if let Some(prev_id) = previously_selected_entry
169                            && let Some(new_ix) = this
170                                .all_entries
171                                .iter()
172                                .position(|probe| probe.id() == prev_id)
173                        {
174                            this.set_selected_entry_index(new_ix, cx);
175                        }
176                    }
177                    SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => {
178                        this.search(query.clone(), cx);
179                    }
180                }
181
182                cx.notify();
183            })
184            .log_err();
185        });
186        self._separated_items_task = Some(task);
187    }
188
189    fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
190        if query.is_empty() {
191            self.search_state = SearchState::Empty;
192            cx.notify();
193            return;
194        }
195
196        let all_entries = self.all_entries.clone();
197
198        let fuzzy_search_task = cx.background_spawn({
199            let query = query.clone();
200            let executor = cx.background_executor().clone();
201            async move {
202                let mut candidates = Vec::with_capacity(all_entries.len());
203
204                for (idx, entry) in all_entries.iter().enumerate() {
205                    candidates.push(StringMatchCandidate::new(idx, entry.title()));
206                }
207
208                const MAX_MATCHES: usize = 100;
209
210                fuzzy::match_strings(
211                    &candidates,
212                    &query,
213                    false,
214                    true,
215                    MAX_MATCHES,
216                    &Default::default(),
217                    executor,
218                )
219                .await
220            }
221        });
222
223        let task = cx.spawn({
224            let query = query.clone();
225            async move |this, cx| {
226                let matches = fuzzy_search_task.await;
227
228                this.update(cx, |this, cx| {
229                    let SearchState::Searching {
230                        query: current_query,
231                        _task,
232                    } = &this.search_state
233                    else {
234                        return;
235                    };
236
237                    if &query == current_query {
238                        this.search_state = SearchState::Searched {
239                            query: query.clone(),
240                            matches,
241                        };
242
243                        this.set_selected_entry_index(0, cx);
244                        cx.notify();
245                    };
246                })
247                .log_err();
248            }
249        });
250
251        self.search_state = SearchState::Searching { query, _task: task };
252        cx.notify();
253    }
254
255    fn matched_count(&self) -> usize {
256        match &self.search_state {
257            SearchState::Empty => self.all_entries.len(),
258            SearchState::Searching { .. } => 0,
259            SearchState::Searched { matches, .. } => matches.len(),
260        }
261    }
262
263    fn list_item_count(&self) -> usize {
264        match &self.search_state {
265            SearchState::Empty => self.separated_items.len(),
266            SearchState::Searching { .. } => 0,
267            SearchState::Searched { matches, .. } => matches.len(),
268        }
269    }
270
271    fn search_produced_no_matches(&self) -> bool {
272        match &self.search_state {
273            SearchState::Empty => false,
274            SearchState::Searching { .. } => false,
275            SearchState::Searched { matches, .. } => matches.is_empty(),
276        }
277    }
278
279    fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
280        match &self.search_state {
281            SearchState::Empty => self.all_entries.get(ix),
282            SearchState::Searching { .. } => None,
283            SearchState::Searched { matches, .. } => matches
284                .get(ix)
285                .and_then(|m| self.all_entries.get(m.candidate_id)),
286        }
287    }
288
289    pub fn select_previous(
290        &mut self,
291        _: &menu::SelectPrevious,
292        _window: &mut Window,
293        cx: &mut Context<Self>,
294    ) {
295        let count = self.matched_count();
296        if count > 0 {
297            if self.selected_index == 0 {
298                self.set_selected_entry_index(count - 1, cx);
299            } else {
300                self.set_selected_entry_index(self.selected_index - 1, cx);
301            }
302        }
303    }
304
305    pub fn select_next(
306        &mut self,
307        _: &menu::SelectNext,
308        _window: &mut Window,
309        cx: &mut Context<Self>,
310    ) {
311        let count = self.matched_count();
312        if count > 0 {
313            if self.selected_index == count - 1 {
314                self.set_selected_entry_index(0, cx);
315            } else {
316                self.set_selected_entry_index(self.selected_index + 1, cx);
317            }
318        }
319    }
320
321    fn select_first(
322        &mut self,
323        _: &menu::SelectFirst,
324        _window: &mut Window,
325        cx: &mut Context<Self>,
326    ) {
327        let count = self.matched_count();
328        if count > 0 {
329            self.set_selected_entry_index(0, cx);
330        }
331    }
332
333    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
334        let count = self.matched_count();
335        if count > 0 {
336            self.set_selected_entry_index(count - 1, cx);
337        }
338    }
339
340    fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context<Self>) {
341        self.selected_index = entry_index;
342
343        let scroll_ix = match self.search_state {
344            SearchState::Empty | SearchState::Searching { .. } => self
345                .separated_item_indexes
346                .get(entry_index)
347                .map(|ix| *ix as usize)
348                .unwrap_or(entry_index + 1),
349            SearchState::Searched { .. } => entry_index,
350        };
351
352        self.scroll_handle
353            .scroll_to_item(scroll_ix, ScrollStrategy::Top);
354
355        cx.notify();
356    }
357
358    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
359        if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) {
360            return None;
361        }
362
363        Some(
364            div()
365                .occlude()
366                .id("thread-history-scroll")
367                .h_full()
368                .bg(cx.theme().colors().panel_background.opacity(0.8))
369                .border_l_1()
370                .border_color(cx.theme().colors().border_variant)
371                .absolute()
372                .right_1()
373                .top_0()
374                .bottom_0()
375                .w_4()
376                .pl_1()
377                .cursor_default()
378                .on_mouse_move(cx.listener(|_, _, _window, cx| {
379                    cx.notify();
380                    cx.stop_propagation()
381                }))
382                .on_hover(|_, _window, cx| {
383                    cx.stop_propagation();
384                })
385                .on_any_mouse_down(|_, _window, cx| {
386                    cx.stop_propagation();
387                })
388                .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
389                    cx.notify();
390                }))
391                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
392        )
393    }
394
395    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
396        self.confirm_entry(self.selected_index, cx);
397    }
398
399    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
400        let Some(entry) = self.get_match(ix) else {
401            return;
402        };
403        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
404    }
405
406    fn remove_selected_thread(
407        &mut self,
408        _: &RemoveSelectedThread,
409        _window: &mut Window,
410        cx: &mut Context<Self>,
411    ) {
412        self.remove_thread(self.selected_index, cx)
413    }
414
415    fn remove_thread(&mut self, ix: usize, cx: &mut Context<Self>) {
416        let Some(entry) = self.get_match(ix) else {
417            return;
418        };
419
420        let task = match entry {
421            HistoryEntry::AcpThread(thread) => self
422                .history_store
423                .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
424            HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| {
425                this.delete_text_thread(context.path.clone(), cx)
426            }),
427        };
428        task.detach_and_log_err(cx);
429    }
430
431    fn list_items(
432        &mut self,
433        range: Range<usize>,
434        _window: &mut Window,
435        cx: &mut Context<Self>,
436    ) -> Vec<AnyElement> {
437        match &self.search_state {
438            SearchState::Empty => self
439                .separated_items
440                .get(range)
441                .iter()
442                .flat_map(|items| {
443                    items
444                        .iter()
445                        .map(|item| self.render_list_item(item, vec![], cx))
446                })
447                .collect(),
448            SearchState::Searched { matches, .. } => matches[range]
449                .iter()
450                .filter_map(|m| {
451                    let entry = self.all_entries.get(m.candidate_id)?;
452                    Some(self.render_history_entry(
453                        entry,
454                        EntryTimeFormat::DateAndTime,
455                        m.candidate_id,
456                        m.positions.clone(),
457                        cx,
458                    ))
459                })
460                .collect(),
461            SearchState::Searching { .. } => {
462                vec![]
463            }
464        }
465    }
466
467    fn render_list_item(
468        &self,
469        item: &ListItemType,
470        highlight_positions: Vec<usize>,
471        cx: &Context<Self>,
472    ) -> AnyElement {
473        match item {
474            ListItemType::Entry { index, format } => match self.all_entries.get(*index) {
475                Some(entry) => self
476                    .render_history_entry(entry, *format, *index, highlight_positions, cx)
477                    .into_any(),
478                None => Empty.into_any_element(),
479            },
480            ListItemType::BucketSeparator(bucket) => div()
481                .px(DynamicSpacing::Base06.rems(cx))
482                .pt_2()
483                .pb_1()
484                .child(
485                    Label::new(bucket.to_string())
486                        .size(LabelSize::XSmall)
487                        .color(Color::Muted),
488                )
489                .into_any_element(),
490        }
491    }
492
493    fn render_history_entry(
494        &self,
495        entry: &HistoryEntry,
496        format: EntryTimeFormat,
497        list_entry_ix: usize,
498        highlight_positions: Vec<usize>,
499        cx: &Context<Self>,
500    ) -> AnyElement {
501        let selected = list_entry_ix == self.selected_index;
502        let hovered = Some(list_entry_ix) == self.hovered_index;
503        let timestamp = entry.updated_at().timestamp();
504        let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
505
506        h_flex()
507            .w_full()
508            .pb_1()
509            .child(
510                ListItem::new(list_entry_ix)
511                    .rounded()
512                    .toggle_state(selected)
513                    .spacing(ListItemSpacing::Sparse)
514                    .start_slot(
515                        h_flex()
516                            .w_full()
517                            .gap_2()
518                            .justify_between()
519                            .child(
520                                HighlightedLabel::new(entry.title(), highlight_positions)
521                                    .size(LabelSize::Small)
522                                    .truncate(),
523                            )
524                            .child(
525                                Label::new(thread_timestamp)
526                                    .color(Color::Muted)
527                                    .size(LabelSize::XSmall),
528                            ),
529                    )
530                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
531                        if *is_hovered {
532                            this.hovered_index = Some(list_entry_ix);
533                        } else if this.hovered_index == Some(list_entry_ix) {
534                            this.hovered_index = None;
535                        }
536
537                        cx.notify();
538                    }))
539                    .end_slot::<IconButton>(if hovered || selected {
540                        Some(
541                            IconButton::new("delete", IconName::Trash)
542                                .shape(IconButtonShape::Square)
543                                .icon_size(IconSize::XSmall)
544                                .icon_color(Color::Muted)
545                                .tooltip(move |window, cx| {
546                                    Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
547                                })
548                                .on_click(cx.listener(move |this, _, _, cx| {
549                                    this.remove_thread(list_entry_ix, cx)
550                                })),
551                        )
552                    } else {
553                        None
554                    })
555                    .on_click(
556                        cx.listener(move |this, _, _, cx| this.confirm_entry(list_entry_ix, cx)),
557                    ),
558            )
559            .into_any_element()
560    }
561}
562
563impl Focusable for AcpThreadHistory {
564    fn focus_handle(&self, cx: &App) -> FocusHandle {
565        self.search_editor.focus_handle(cx)
566    }
567}
568
569impl Render for AcpThreadHistory {
570    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
571        v_flex()
572            .key_context("ThreadHistory")
573            .size_full()
574            .on_action(cx.listener(Self::select_previous))
575            .on_action(cx.listener(Self::select_next))
576            .on_action(cx.listener(Self::select_first))
577            .on_action(cx.listener(Self::select_last))
578            .on_action(cx.listener(Self::confirm))
579            .on_action(cx.listener(Self::remove_selected_thread))
580            .when(!self.all_entries.is_empty(), |parent| {
581                parent.child(
582                    h_flex()
583                        .h(px(41.)) // Match the toolbar perfectly
584                        .w_full()
585                        .py_1()
586                        .px_2()
587                        .gap_2()
588                        .justify_between()
589                        .border_b_1()
590                        .border_color(cx.theme().colors().border)
591                        .child(
592                            Icon::new(IconName::MagnifyingGlass)
593                                .color(Color::Muted)
594                                .size(IconSize::Small),
595                        )
596                        .child(self.search_editor.clone()),
597                )
598            })
599            .child({
600                let view = v_flex()
601                    .id("list-container")
602                    .relative()
603                    .overflow_hidden()
604                    .flex_grow();
605
606                if self.all_entries.is_empty() {
607                    view.justify_center()
608                        .child(
609                            h_flex().w_full().justify_center().child(
610                                Label::new("You don't have any past threads yet.")
611                                    .size(LabelSize::Small),
612                            ),
613                        )
614                } else if self.search_produced_no_matches() {
615                    view.justify_center().child(
616                        h_flex().w_full().justify_center().child(
617                            Label::new("No threads match your search.").size(LabelSize::Small),
618                        ),
619                    )
620                } else {
621                    view.pr_5()
622                        .child(
623                            uniform_list(
624                                "thread-history",
625                                self.list_item_count(),
626                                cx.processor(|this, range: Range<usize>, window, cx| {
627                                    this.list_items(range, window, cx)
628                                }),
629                            )
630                            .p_1()
631                            .track_scroll(self.scroll_handle.clone())
632                            .flex_grow(),
633                        )
634                        .when_some(self.render_scrollbar(cx), |div, scrollbar| {
635                            div.child(scrollbar)
636                        })
637                }
638            })
639    }
640}
641
642#[derive(Clone, Copy)]
643pub enum EntryTimeFormat {
644    DateAndTime,
645    TimeOnly,
646}
647
648impl EntryTimeFormat {
649    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
650        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
651
652        match self {
653            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
654                timestamp,
655                OffsetDateTime::now_utc(),
656                timezone,
657                time_format::TimestampFormat::EnhancedAbsolute,
658            ),
659            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
660        }
661    }
662}
663
664impl From<TimeBucket> for EntryTimeFormat {
665    fn from(bucket: TimeBucket) -> Self {
666        match bucket {
667            TimeBucket::Today => EntryTimeFormat::TimeOnly,
668            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
669            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
670            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
671            TimeBucket::All => EntryTimeFormat::DateAndTime,
672        }
673    }
674}
675
676#[derive(PartialEq, Eq, Clone, Copy, Debug)]
677enum TimeBucket {
678    Today,
679    Yesterday,
680    ThisWeek,
681    PastWeek,
682    All,
683}
684
685impl TimeBucket {
686    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
687        if date == reference {
688            return TimeBucket::Today;
689        }
690
691        if date == reference - TimeDelta::days(1) {
692            return TimeBucket::Yesterday;
693        }
694
695        let week = date.iso_week();
696
697        if reference.iso_week() == week {
698            return TimeBucket::ThisWeek;
699        }
700
701        let last_week = (reference - TimeDelta::days(7)).iso_week();
702
703        if week == last_week {
704            return TimeBucket::PastWeek;
705        }
706
707        TimeBucket::All
708    }
709}
710
711impl Display for TimeBucket {
712    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
713        match self {
714            TimeBucket::Today => write!(f, "Today"),
715            TimeBucket::Yesterday => write!(f, "Yesterday"),
716            TimeBucket::ThisWeek => write!(f, "This Week"),
717            TimeBucket::PastWeek => write!(f, "Past Week"),
718            TimeBucket::All => write!(f, "All"),
719        }
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use chrono::NaiveDate;
727
728    #[test]
729    fn test_time_bucket_from_dates() {
730        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
731
732        let date = today;
733        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
734
735        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
736        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
737
738        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
739        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
740
741        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
742        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
743
744        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
745        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
746
747        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
748        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
749
750        // All: not in this week or last week
751        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
752        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
753
754        // Test year boundary cases
755        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
756
757        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
758        assert_eq!(
759            TimeBucket::from_dates(new_year, date),
760            TimeBucket::Yesterday
761        );
762
763        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
764        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
765    }
766}