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}