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}