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