history_store.rs

  1use crate::{ThreadId, thread_store::SerializedThreadMetadata};
  2use anyhow::{Context as _, Result};
  3use assistant_context::SavedContextMetadata;
  4use chrono::{DateTime, Utc};
  5use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*};
  6use itertools::Itertools;
  7use paths::contexts_dir;
  8use serde::{Deserialize, Serialize};
  9use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration};
 10use util::ResultExt as _;
 11
 12const MAX_RECENTLY_OPENED_ENTRIES: usize = 6;
 13const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json";
 14const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50);
 15
 16#[derive(Clone, Debug)]
 17pub enum HistoryEntry {
 18    Thread(SerializedThreadMetadata),
 19    Context(SavedContextMetadata),
 20}
 21
 22impl HistoryEntry {
 23    pub fn updated_at(&self) -> DateTime<Utc> {
 24        match self {
 25            HistoryEntry::Thread(thread) => thread.updated_at,
 26            HistoryEntry::Context(context) => context.mtime.to_utc(),
 27        }
 28    }
 29
 30    pub fn id(&self) -> HistoryEntryId {
 31        match self {
 32            HistoryEntry::Thread(thread) => HistoryEntryId::Thread(thread.id.clone()),
 33            HistoryEntry::Context(context) => HistoryEntryId::Context(context.path.clone()),
 34        }
 35    }
 36
 37    pub fn title(&self) -> &SharedString {
 38        match self {
 39            HistoryEntry::Thread(thread) => &thread.summary,
 40            HistoryEntry::Context(context) => &context.title,
 41        }
 42    }
 43}
 44
 45/// Generic identifier for a history entry.
 46#[derive(Clone, PartialEq, Eq, Debug)]
 47pub enum HistoryEntryId {
 48    Thread(ThreadId),
 49    Context(Arc<Path>),
 50}
 51
 52#[derive(Serialize, Deserialize)]
 53enum SerializedRecentOpen {
 54    Thread(String),
 55    ContextName(String),
 56    /// Old format which stores the full path
 57    Context(String),
 58}
 59
 60pub struct HistoryStore {
 61    context_store: Entity<assistant_context::ContextStore>,
 62    recently_opened_entries: VecDeque<HistoryEntryId>,
 63    _subscriptions: Vec<gpui::Subscription>,
 64    _save_recently_opened_entries_task: Task<()>,
 65}
 66
 67impl HistoryStore {
 68    pub fn new(
 69        context_store: Entity<assistant_context::ContextStore>,
 70        initial_recent_entries: impl IntoIterator<Item = HistoryEntryId>,
 71        cx: &mut Context<Self>,
 72    ) -> Self {
 73        let subscriptions = vec![cx.observe(&context_store, |_, _, cx| cx.notify())];
 74
 75        cx.spawn(async move |this, cx| {
 76            let entries = Self::load_recently_opened_entries(cx).await.log_err()?;
 77            this.update(cx, |this, _| {
 78                this.recently_opened_entries
 79                    .extend(
 80                        entries.into_iter().take(
 81                            MAX_RECENTLY_OPENED_ENTRIES
 82                                .saturating_sub(this.recently_opened_entries.len()),
 83                        ),
 84                    );
 85            })
 86            .ok()
 87        })
 88        .detach();
 89
 90        Self {
 91            context_store,
 92            recently_opened_entries: initial_recent_entries.into_iter().collect(),
 93            _subscriptions: subscriptions,
 94            _save_recently_opened_entries_task: Task::ready(()),
 95        }
 96    }
 97
 98    pub fn entries(&self, cx: &mut Context<Self>) -> Vec<HistoryEntry> {
 99        let mut history_entries = Vec::new();
100
101        #[cfg(debug_assertions)]
102        if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
103            return history_entries;
104        }
105
106        history_entries.extend(
107            self.context_store
108                .read(cx)
109                .unordered_contexts()
110                .cloned()
111                .map(HistoryEntry::Context),
112        );
113
114        history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at()));
115        history_entries
116    }
117
118    pub fn recent_entries(&self, limit: usize, cx: &mut Context<Self>) -> Vec<HistoryEntry> {
119        self.entries(cx).into_iter().take(limit).collect()
120    }
121
122    pub fn recently_opened_entries(&self, cx: &App) -> Vec<HistoryEntry> {
123        #[cfg(debug_assertions)]
124        if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
125            return Vec::new();
126        }
127
128        let context_entries =
129            self.context_store
130                .read(cx)
131                .unordered_contexts()
132                .flat_map(|context| {
133                    self.recently_opened_entries
134                        .iter()
135                        .enumerate()
136                        .flat_map(|(index, entry)| match entry {
137                            HistoryEntryId::Context(path) if &context.path == path => {
138                                Some((index, HistoryEntry::Context(context.clone())))
139                            }
140                            _ => None,
141                        })
142                });
143
144        context_entries
145            // optimization to halt iteration early
146            .take(self.recently_opened_entries.len())
147            .sorted_unstable_by_key(|(index, _)| *index)
148            .map(|(_, entry)| entry)
149            .collect()
150    }
151
152    fn save_recently_opened_entries(&mut self, cx: &mut Context<Self>) {
153        let serialized_entries = self
154            .recently_opened_entries
155            .iter()
156            .filter_map(|entry| match entry {
157                HistoryEntryId::Context(path) => path.file_name().map(|file| {
158                    SerializedRecentOpen::ContextName(file.to_string_lossy().to_string())
159                }),
160                HistoryEntryId::Thread(id) => Some(SerializedRecentOpen::Thread(id.to_string())),
161            })
162            .collect::<Vec<_>>();
163
164        self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| {
165            cx.background_executor()
166                .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE)
167                .await;
168            cx.background_spawn(async move {
169                let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
170                let content = serde_json::to_string(&serialized_entries)?;
171                std::fs::write(path, content)?;
172                anyhow::Ok(())
173            })
174            .await
175            .log_err();
176        });
177    }
178
179    fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<Vec<HistoryEntryId>>> {
180        cx.background_spawn(async move {
181            let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
182            let contents = match smol::fs::read_to_string(path).await {
183                Ok(it) => it,
184                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
185                    return Ok(Vec::new());
186                }
187                Err(e) => {
188                    return Err(e)
189                        .context("deserializing persisted agent panel navigation history");
190                }
191            };
192            let entries = serde_json::from_str::<Vec<SerializedRecentOpen>>(&contents)
193                .context("deserializing persisted agent panel navigation history")?
194                .into_iter()
195                .take(MAX_RECENTLY_OPENED_ENTRIES)
196                .flat_map(|entry| match entry {
197                    SerializedRecentOpen::Thread(id) => {
198                        Some(HistoryEntryId::Thread(id.as_str().into()))
199                    }
200                    SerializedRecentOpen::ContextName(file_name) => Some(HistoryEntryId::Context(
201                        contexts_dir().join(file_name).into(),
202                    )),
203                    SerializedRecentOpen::Context(path) => {
204                        Path::new(&path).file_name().map(|file_name| {
205                            HistoryEntryId::Context(contexts_dir().join(file_name).into())
206                        })
207                    }
208                })
209                .collect::<Vec<_>>();
210            Ok(entries)
211        })
212    }
213
214    pub fn push_recently_opened_entry(&mut self, entry: HistoryEntryId, cx: &mut Context<Self>) {
215        self.recently_opened_entries
216            .retain(|old_entry| old_entry != &entry);
217        self.recently_opened_entries.push_front(entry);
218        self.recently_opened_entries
219            .truncate(MAX_RECENTLY_OPENED_ENTRIES);
220        self.save_recently_opened_entries(cx);
221    }
222
223    pub fn remove_recently_opened_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
224        self.recently_opened_entries.retain(
225            |entry| !matches!(entry, HistoryEntryId::Thread(thread_id) if thread_id == &id),
226        );
227        self.save_recently_opened_entries(cx);
228    }
229
230    pub fn replace_recently_opened_text_thread(
231        &mut self,
232        old_path: &Path,
233        new_path: &Arc<Path>,
234        cx: &mut Context<Self>,
235    ) {
236        for entry in &mut self.recently_opened_entries {
237            match entry {
238                HistoryEntryId::Context(path) if path.as_ref() == old_path => {
239                    *entry = HistoryEntryId::Context(new_path.clone());
240                    break;
241                }
242                _ => {}
243            }
244        }
245        self.save_recently_opened_entries(cx);
246    }
247
248    pub fn remove_recently_opened_entry(&mut self, entry: &HistoryEntryId, cx: &mut Context<Self>) {
249        self.recently_opened_entries
250            .retain(|old_entry| old_entry != entry);
251        self.save_recently_opened_entries(cx);
252    }
253}