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