history_store.rs

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