1use std::{
2 path::{Path, PathBuf},
3 sync::Arc,
4};
5
6use acp_thread::AcpThreadEvent;
7use agent::{ThreadStore, ZED_AGENT_ID};
8use agent_client_protocol as acp;
9use anyhow::Context as _;
10use chrono::{DateTime, Utc};
11use collections::{HashMap, HashSet};
12use db::{
13 sqlez::{
14 bindable::Column, domain::Domain, statement::Statement,
15 thread_safe_connection::ThreadSafeConnection,
16 },
17 sqlez_macros::sql,
18};
19use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
20use futures::{FutureExt as _, future::Shared};
21use gpui::{AppContext as _, Entity, Global, Subscription, Task};
22use project::AgentId;
23use ui::{App, Context, SharedString};
24use util::ResultExt as _;
25use workspace::PathList;
26
27use crate::DEFAULT_THREAD_TITLE;
28
29pub fn init(cx: &mut App) {
30 ThreadMetadataStore::init_global(cx);
31
32 if cx.has_flag::<AgentV2FeatureFlag>() {
33 migrate_thread_metadata(cx);
34 }
35 cx.observe_flag::<AgentV2FeatureFlag, _>(|has_flag, cx| {
36 if has_flag {
37 migrate_thread_metadata(cx);
38 }
39 })
40 .detach();
41}
42
43/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
44/// We skip migrating threads that do not have a project.
45///
46/// TODO: Remove this after N weeks of shipping the sidebar
47fn migrate_thread_metadata(cx: &mut App) {
48 let store = ThreadMetadataStore::global(cx);
49 let db = store.read(cx).db.clone();
50
51 cx.spawn(async move |cx| {
52 let existing_entries = db.list_ids()?.into_iter().collect::<HashSet<_>>();
53
54 let is_first_migration = existing_entries.is_empty();
55
56 let mut to_migrate = store.read_with(cx, |_store, cx| {
57 ThreadStore::global(cx)
58 .read(cx)
59 .entries()
60 .filter_map(|entry| {
61 if existing_entries.contains(&entry.id.0) {
62 return None;
63 }
64
65 Some(ThreadMetadata {
66 session_id: entry.id,
67 agent_id: ZED_AGENT_ID.clone(),
68 title: entry.title,
69 updated_at: entry.updated_at,
70 created_at: entry.created_at,
71 folder_paths: entry.folder_paths,
72 main_worktree_paths: PathList::default(),
73 archived: true,
74 pending_worktree_restore: None,
75 })
76 })
77 .collect::<Vec<_>>()
78 });
79
80 if to_migrate.is_empty() {
81 return anyhow::Ok(());
82 }
83
84 // On the first migration (no entries in DB yet), keep the 5 most
85 // recent threads per project unarchived.
86 if is_first_migration {
87 let mut per_project: HashMap<PathList, Vec<&mut ThreadMetadata>> = HashMap::default();
88 for entry in &mut to_migrate {
89 if entry.folder_paths.is_empty() {
90 continue;
91 }
92 per_project
93 .entry(entry.folder_paths.clone())
94 .or_default()
95 .push(entry);
96 }
97 for entries in per_project.values_mut() {
98 entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
99 for entry in entries.iter_mut().take(5) {
100 entry.archived = false;
101 }
102 }
103 }
104
105 log::info!("Migrating {} thread store entries", to_migrate.len());
106
107 // Manually save each entry to the database and call reload, otherwise
108 // we'll end up triggering lots of reloads after each save
109 for entry in to_migrate {
110 db.save(entry).await?;
111 }
112
113 log::info!("Finished migrating thread store entries");
114
115 let _ = store.update(cx, |store, cx| store.reload(cx));
116 anyhow::Ok(())
117 })
118 .detach_and_log_err(cx);
119}
120
121struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
122impl Global for GlobalThreadMetadataStore {}
123
124/// Lightweight metadata for any thread (native or ACP), enough to populate
125/// the sidebar list and route to the correct load path when clicked.
126#[derive(Debug, Clone, PartialEq)]
127pub struct ThreadMetadata {
128 pub session_id: acp::SessionId,
129 pub agent_id: AgentId,
130 pub title: SharedString,
131 pub updated_at: DateTime<Utc>,
132 pub created_at: Option<DateTime<Utc>>,
133 pub folder_paths: PathList,
134 pub main_worktree_paths: PathList,
135 pub archived: bool,
136 /// When set, the thread's original worktree is being restored in the background.
137 /// The PathBuf is the main repo path being used temporarily while restoration is pending.
138 pub pending_worktree_restore: Option<PathBuf>,
139}
140
141impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
142 fn from(meta: &ThreadMetadata) -> Self {
143 Self {
144 session_id: meta.session_id.clone(),
145 work_dirs: Some(meta.folder_paths.clone()),
146 title: Some(meta.title.clone()),
147 updated_at: Some(meta.updated_at),
148 created_at: meta.created_at,
149 meta: None,
150 }
151 }
152}
153
154/// Record of a git worktree that was archived (deleted from disk) when its last thread was archived.
155pub struct ArchivedGitWorktree {
156 /// Auto-incrementing primary key
157 pub id: i64,
158 /// Absolute path to the directory of the worktree before it was deleted.
159 /// Used when restoring, to put the recreated worktree back where it was.
160 /// (If that dir is already taken when restoring, we auto-generate a new dir.)
161 pub worktree_path: PathBuf,
162 /// Absolute path of the main repository ("main worktree") that owned this worktree.
163 /// Used when restoring, to reattach the recreated worktree to the correct main worktree.
164 /// If the main repo isn't found on disk, unarchiving fails because we only store the
165 /// commit hash, and without the actual git repo being available, we can't restore the files.
166 pub main_repo_path: PathBuf,
167 /// Branch that was checked out in the worktree at archive time. `None` if
168 /// the worktree was in detached HEAD state, which isn't supported in Zed, but
169 /// could happen if the user made a detached one outside of Zed.
170 /// On restore, we try to switch to this branch. If that fails (e.g. it's
171 /// checked out elsewhere), we try to create it. If both fail, the restored
172 /// worktree stays in detached HEAD and a warning is logged.
173 pub branch_name: Option<String>,
174 /// SHA of the WIP commit that we made in order to save the files that were uncommitted
175 /// and unstaged at the time of archiving. This commit can be empty. Its parent commit
176 /// will also be a WIP that we created during archiving, which contains the files that
177 /// were staged at the time of archiving. (It can also be empty.) After doing `git reset`
178 /// past both of these commits, we're back in the state we had before archiving, including
179 /// what was staged, what was unstaged, and what was committed.
180 pub commit_hash: String,
181 /// Whether this worktree has been restored by a prior unarchive.
182 /// Currently unused — the record is deleted immediately after a
183 /// successful restore rather than being marked as restored.
184 pub restored: bool,
185}
186
187/// The store holds all metadata needed to show threads in the sidebar/the archive.
188///
189/// Automatically listens to AcpThread events and updates metadata if it has changed.
190pub struct ThreadMetadataStore {
191 db: ThreadMetadataDb,
192 threads: HashMap<acp::SessionId, ThreadMetadata>,
193 threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
194 threads_by_main_paths: HashMap<PathList, HashSet<acp::SessionId>>,
195 reload_task: Option<Shared<Task<()>>>,
196 session_subscriptions: HashMap<acp::SessionId, Subscription>,
197 pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
198 _db_operations_task: Task<()>,
199}
200
201#[derive(Debug, PartialEq)]
202enum DbOperation {
203 Upsert(ThreadMetadata),
204 Delete(acp::SessionId),
205}
206
207impl DbOperation {
208 fn id(&self) -> &acp::SessionId {
209 match self {
210 DbOperation::Upsert(thread) => &thread.session_id,
211 DbOperation::Delete(session_id) => session_id,
212 }
213 }
214}
215
216impl ThreadMetadataStore {
217 #[cfg(not(any(test, feature = "test-support")))]
218 pub fn init_global(cx: &mut App) {
219 if cx.has_global::<Self>() {
220 return;
221 }
222
223 let db = ThreadMetadataDb::global(cx);
224 let thread_store = cx.new(|cx| Self::new(db, cx));
225 cx.set_global(GlobalThreadMetadataStore(thread_store));
226 }
227
228 #[cfg(any(test, feature = "test-support"))]
229 pub fn init_global(cx: &mut App) {
230 let thread = std::thread::current();
231 let test_name = thread.name().unwrap_or("unknown_test");
232 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
233 let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
234 let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
235 cx.set_global(GlobalThreadMetadataStore(thread_store));
236 }
237
238 pub fn try_global(cx: &App) -> Option<Entity<Self>> {
239 cx.try_global::<GlobalThreadMetadataStore>()
240 .map(|store| store.0.clone())
241 }
242
243 pub fn global(cx: &App) -> Entity<Self> {
244 cx.global::<GlobalThreadMetadataStore>().0.clone()
245 }
246
247 pub fn is_empty(&self) -> bool {
248 self.threads.is_empty()
249 }
250
251 /// Returns all thread IDs.
252 pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
253 self.threads.keys().cloned()
254 }
255
256 /// Returns the metadata for a specific thread, if it exists.
257 pub fn entry(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> {
258 self.threads.get(session_id)
259 }
260
261 /// Returns all threads.
262 pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
263 self.threads.values()
264 }
265
266 /// Returns all archived threads.
267 pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
268 self.entries().filter(|t| t.archived)
269 }
270
271 /// Returns all session IDs for the given path list, including archived threads.
272 pub fn all_session_ids_for_path(
273 &self,
274 path_list: &PathList,
275 ) -> impl Iterator<Item = &acp::SessionId> + '_ {
276 self.threads_by_paths.get(path_list).into_iter().flatten()
277 }
278
279 /// Returns all threads for the given path list, excluding archived threads.
280 pub fn entries_for_path(
281 &self,
282 path_list: &PathList,
283 ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
284 self.threads_by_paths
285 .get(path_list)
286 .into_iter()
287 .flatten()
288 .filter_map(|s| self.threads.get(s))
289 .filter(|s| !s.archived)
290 }
291
292 /// Returns threads whose `main_worktree_paths` matches the given path list,
293 /// excluding archived threads. This finds threads that were opened in a
294 /// linked worktree but are associated with the given main worktree.
295 pub fn entries_for_main_worktree_path(
296 &self,
297 path_list: &PathList,
298 ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
299 self.threads_by_main_paths
300 .get(path_list)
301 .into_iter()
302 .flatten()
303 .filter_map(|s| self.threads.get(s))
304 .filter(|s| !s.archived)
305 }
306
307 fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
308 let db = self.db.clone();
309 self.reload_task.take();
310
311 let list_task = cx
312 .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
313
314 let reload_task = cx
315 .spawn(async move |this, cx| {
316 let Some(rows) = list_task.await.log_err() else {
317 return;
318 };
319
320 this.update(cx, |this, cx| {
321 this.threads.clear();
322 this.threads_by_paths.clear();
323 this.threads_by_main_paths.clear();
324
325 for row in rows {
326 this.threads_by_paths
327 .entry(row.folder_paths.clone())
328 .or_default()
329 .insert(row.session_id.clone());
330 if !row.main_worktree_paths.is_empty() {
331 this.threads_by_main_paths
332 .entry(row.main_worktree_paths.clone())
333 .or_default()
334 .insert(row.session_id.clone());
335 }
336 this.threads.insert(row.session_id.clone(), row);
337 }
338
339 cx.notify();
340 })
341 .ok();
342 })
343 .shared();
344 self.reload_task = Some(reload_task.clone());
345 reload_task
346 }
347
348 pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
349 if !cx.has_flag::<AgentV2FeatureFlag>() {
350 return;
351 }
352
353 for metadata in metadata {
354 self.save_internal(metadata);
355 }
356 cx.notify();
357 }
358
359 #[cfg(any(test, feature = "test-support"))]
360 pub fn save_manually(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
361 self.save(metadata, cx)
362 }
363
364 fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
365 if !cx.has_flag::<AgentV2FeatureFlag>() {
366 return;
367 }
368
369 self.save_internal(metadata);
370 cx.notify();
371 }
372
373 fn save_internal(&mut self, metadata: ThreadMetadata) {
374 if let Some(thread) = self.threads.get(&metadata.session_id) {
375 if thread.folder_paths != metadata.folder_paths {
376 if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
377 session_ids.remove(&metadata.session_id);
378 }
379 }
380 if thread.main_worktree_paths != metadata.main_worktree_paths
381 && !thread.main_worktree_paths.is_empty()
382 {
383 if let Some(session_ids) = self
384 .threads_by_main_paths
385 .get_mut(&thread.main_worktree_paths)
386 {
387 session_ids.remove(&metadata.session_id);
388 }
389 }
390 }
391
392 self.threads
393 .insert(metadata.session_id.clone(), metadata.clone());
394
395 self.threads_by_paths
396 .entry(metadata.folder_paths.clone())
397 .or_default()
398 .insert(metadata.session_id.clone());
399
400 if !metadata.main_worktree_paths.is_empty() {
401 self.threads_by_main_paths
402 .entry(metadata.main_worktree_paths.clone())
403 .or_default()
404 .insert(metadata.session_id.clone());
405 }
406
407 self.pending_thread_ops_tx
408 .try_send(DbOperation::Upsert(metadata))
409 .log_err();
410 }
411
412 pub fn update_working_directories(
413 &mut self,
414 session_id: &acp::SessionId,
415 work_dirs: PathList,
416 cx: &mut Context<Self>,
417 ) {
418 if !cx.has_flag::<AgentV2FeatureFlag>() {
419 return;
420 }
421
422 if let Some(thread) = self.threads.get(session_id) {
423 self.save_internal(ThreadMetadata {
424 folder_paths: work_dirs,
425 ..thread.clone()
426 });
427 cx.notify();
428 }
429 }
430
431 pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
432 self.update_archived(session_id, true, cx);
433 }
434
435 pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
436 self.update_archived(session_id, false, cx);
437 }
438
439 pub fn create_archived_worktree(
440 &self,
441 worktree_path: String,
442 main_repo_path: String,
443 branch_name: Option<String>,
444 commit_hash: String,
445 cx: &mut Context<Self>,
446 ) -> Task<anyhow::Result<i64>> {
447 let db = self.db.clone();
448 cx.background_spawn(async move {
449 db.create_archived_worktree(
450 &worktree_path,
451 &main_repo_path,
452 branch_name.as_deref(),
453 &commit_hash,
454 )
455 .await
456 })
457 }
458
459 pub fn link_thread_to_archived_worktree(
460 &self,
461 session_id: String,
462 archived_worktree_id: i64,
463 cx: &mut Context<Self>,
464 ) -> Task<anyhow::Result<()>> {
465 let db = self.db.clone();
466 cx.background_spawn(async move {
467 db.link_thread_to_archived_worktree(&session_id, archived_worktree_id)
468 .await
469 })
470 }
471
472 pub fn get_archived_worktrees_for_thread(
473 &self,
474 session_id: String,
475 cx: &mut Context<Self>,
476 ) -> Task<anyhow::Result<Vec<ArchivedGitWorktree>>> {
477 let db = self.db.clone();
478 cx.background_spawn(async move { db.get_archived_worktrees_for_thread(&session_id).await })
479 }
480
481 pub fn delete_archived_worktree(
482 &self,
483 id: i64,
484 cx: &mut Context<Self>,
485 ) -> Task<anyhow::Result<()>> {
486 let db = self.db.clone();
487 cx.background_spawn(async move { db.delete_archived_worktree(id).await })
488 }
489
490 pub fn set_archived_worktree_restored(
491 &self,
492 id: i64,
493 worktree_path: String,
494 branch_name: Option<String>,
495 cx: &mut Context<Self>,
496 ) -> Task<anyhow::Result<()>> {
497 let db = self.db.clone();
498 cx.background_spawn(async move {
499 db.set_archived_worktree_restored(id, &worktree_path, branch_name.as_deref())
500 .await
501 })
502 }
503
504 fn update_archived(
505 &mut self,
506 session_id: &acp::SessionId,
507 archived: bool,
508 cx: &mut Context<Self>,
509 ) {
510 if !cx.has_flag::<AgentV2FeatureFlag>() {
511 return;
512 }
513
514 if let Some(thread) = self.threads.get(session_id) {
515 self.save_internal(ThreadMetadata {
516 archived,
517 ..thread.clone()
518 });
519 cx.notify();
520 }
521 }
522
523 pub fn set_pending_worktree_restore(
524 &mut self,
525 session_id: &acp::SessionId,
526 main_repo_path: Option<PathBuf>,
527 cx: &mut Context<Self>,
528 ) {
529 if let Some(thread) = self.threads.get_mut(session_id) {
530 thread.pending_worktree_restore = main_repo_path;
531 cx.notify();
532 }
533 }
534
535 pub fn complete_worktree_restore(
536 &mut self,
537 session_id: &acp::SessionId,
538 new_folder_paths: PathList,
539 cx: &mut Context<Self>,
540 ) {
541 if let Some(thread) = self.threads.get(session_id).cloned() {
542 let mut updated = thread;
543 updated.pending_worktree_restore = None;
544 updated.folder_paths = new_folder_paths;
545 self.save_internal(updated);
546 cx.notify();
547 }
548 }
549
550 pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
551 if !cx.has_flag::<AgentV2FeatureFlag>() {
552 return;
553 }
554
555 if let Some(thread) = self.threads.get(&session_id) {
556 if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
557 session_ids.remove(&session_id);
558 }
559 if !thread.main_worktree_paths.is_empty() {
560 if let Some(session_ids) = self
561 .threads_by_main_paths
562 .get_mut(&thread.main_worktree_paths)
563 {
564 session_ids.remove(&session_id);
565 }
566 }
567 }
568 self.threads.remove(&session_id);
569 self.pending_thread_ops_tx
570 .try_send(DbOperation::Delete(session_id))
571 .log_err();
572 cx.notify();
573 }
574
575 fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
576 let weak_store = cx.weak_entity();
577
578 cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
579 // Don't track subagent threads in the sidebar.
580 if thread.parent_session_id().is_some() {
581 return;
582 }
583
584 let thread_entity = cx.entity();
585
586 cx.on_release({
587 let weak_store = weak_store.clone();
588 move |thread, cx| {
589 weak_store
590 .update(cx, |store, _cx| {
591 let session_id = thread.session_id().clone();
592 store.session_subscriptions.remove(&session_id);
593 })
594 .ok();
595 }
596 })
597 .detach();
598
599 weak_store
600 .update(cx, |this, cx| {
601 let subscription = cx.subscribe(&thread_entity, Self::handle_thread_event);
602 this.session_subscriptions
603 .insert(thread.session_id().clone(), subscription);
604 })
605 .ok();
606 })
607 .detach();
608
609 let (tx, rx) = smol::channel::unbounded();
610 let _db_operations_task = cx.background_spawn({
611 let db = db.clone();
612 async move {
613 while let Ok(first_update) = rx.recv().await {
614 let mut updates = vec![first_update];
615 while let Ok(update) = rx.try_recv() {
616 updates.push(update);
617 }
618 let updates = Self::dedup_db_operations(updates);
619 for operation in updates {
620 match operation {
621 DbOperation::Upsert(metadata) => {
622 db.save(metadata).await.log_err();
623 }
624 DbOperation::Delete(session_id) => {
625 db.delete(session_id).await.log_err();
626 }
627 }
628 }
629 }
630 }
631 });
632
633 let mut this = Self {
634 db,
635 threads: HashMap::default(),
636 threads_by_paths: HashMap::default(),
637 threads_by_main_paths: HashMap::default(),
638 reload_task: None,
639 session_subscriptions: HashMap::default(),
640 pending_thread_ops_tx: tx,
641 _db_operations_task,
642 };
643 let _ = this.reload(cx);
644 this
645 }
646
647 fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
648 let mut ops = HashMap::default();
649 for operation in operations.into_iter().rev() {
650 if ops.contains_key(operation.id()) {
651 continue;
652 }
653 ops.insert(operation.id().clone(), operation);
654 }
655 ops.into_values().collect()
656 }
657
658 fn handle_thread_event(
659 &mut self,
660 thread: Entity<acp_thread::AcpThread>,
661 event: &AcpThreadEvent,
662 cx: &mut Context<Self>,
663 ) {
664 // Don't track subagent threads in the sidebar.
665 if thread.read(cx).parent_session_id().is_some() {
666 return;
667 }
668
669 match event {
670 AcpThreadEvent::NewEntry
671 | AcpThreadEvent::TitleUpdated
672 | AcpThreadEvent::EntryUpdated(_)
673 | AcpThreadEvent::EntriesRemoved(_)
674 | AcpThreadEvent::ToolAuthorizationRequested(_)
675 | AcpThreadEvent::ToolAuthorizationReceived(_)
676 | AcpThreadEvent::Retry(_)
677 | AcpThreadEvent::Stopped(_)
678 | AcpThreadEvent::Error
679 | AcpThreadEvent::LoadError(_)
680 | AcpThreadEvent::Refusal
681 | AcpThreadEvent::WorkingDirectoriesUpdated => {
682 let thread_ref = thread.read(cx);
683 if thread_ref.entries().is_empty() {
684 return;
685 }
686
687 let existing_thread = self.threads.get(thread_ref.session_id());
688 let session_id = thread_ref.session_id().clone();
689 let title = thread_ref
690 .title()
691 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
692
693 let updated_at = Utc::now();
694
695 let created_at = existing_thread
696 .and_then(|t| t.created_at)
697 .unwrap_or_else(|| updated_at);
698
699 let agent_id = thread_ref.connection().agent_id();
700
701 let folder_paths = {
702 let project = thread_ref.project().read(cx);
703 let paths: Vec<Arc<Path>> = project
704 .visible_worktrees(cx)
705 .map(|worktree| worktree.read(cx).abs_path())
706 .collect();
707 PathList::new(&paths)
708 };
709
710 let main_worktree_paths = {
711 let project = thread_ref.project().read(cx);
712 let mut main_paths: Vec<Arc<Path>> = Vec::new();
713 for repo in project.repositories(cx).values() {
714 let snapshot = repo.read(cx).snapshot();
715 if snapshot.is_linked_worktree() {
716 main_paths.push(snapshot.original_repo_abs_path.clone());
717 }
718 }
719 main_paths.sort();
720 main_paths.dedup();
721 PathList::new(&main_paths)
722 };
723
724 // Threads without a folder path (e.g. started in an empty
725 // window) are archived by default so they don't get lost,
726 // because they won't show up in the sidebar. Users can reload
727 // them from the archive.
728 let archived = existing_thread
729 .map(|t| t.archived)
730 .unwrap_or(folder_paths.is_empty());
731
732 let metadata = ThreadMetadata {
733 session_id,
734 agent_id,
735 title,
736 created_at: Some(created_at),
737 updated_at,
738 folder_paths,
739 main_worktree_paths,
740 archived,
741 pending_worktree_restore: None,
742 };
743
744 self.save(metadata, cx);
745 }
746 AcpThreadEvent::TokenUsageUpdated
747 | AcpThreadEvent::SubagentSpawned(_)
748 | AcpThreadEvent::PromptCapabilitiesUpdated
749 | AcpThreadEvent::AvailableCommandsUpdated(_)
750 | AcpThreadEvent::ModeUpdated(_)
751 | AcpThreadEvent::ConfigOptionsUpdated(_) => {}
752 }
753 }
754}
755
756impl Global for ThreadMetadataStore {}
757
758struct ThreadMetadataDb(ThreadSafeConnection);
759
760impl Domain for ThreadMetadataDb {
761 const NAME: &str = stringify!(ThreadMetadataDb);
762
763 const MIGRATIONS: &[&str] = &[
764 sql!(
765 CREATE TABLE IF NOT EXISTS sidebar_threads(
766 session_id TEXT PRIMARY KEY,
767 agent_id TEXT,
768 title TEXT NOT NULL,
769 updated_at TEXT NOT NULL,
770 created_at TEXT,
771 folder_paths TEXT,
772 folder_paths_order TEXT
773 ) STRICT;
774 ),
775 sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
776 sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
777 sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
778 sql!(
779 CREATE TABLE IF NOT EXISTS archived_git_worktrees(
780 id INTEGER PRIMARY KEY,
781 worktree_path TEXT NOT NULL,
782 main_repo_path TEXT NOT NULL,
783 branch_name TEXT,
784 commit_hash TEXT NOT NULL,
785 restored INTEGER NOT NULL DEFAULT 0
786 ) STRICT;
787 CREATE TABLE IF NOT EXISTS thread_archived_worktrees(
788 session_id TEXT NOT NULL,
789 archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
790 PRIMARY KEY (session_id, archived_worktree_id)
791 ) STRICT;
792 ),
793 ];
794}
795
796db::static_connection!(ThreadMetadataDb, []);
797
798impl ThreadMetadataDb {
799 pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
800 self.select::<Arc<str>>(
801 "SELECT session_id FROM sidebar_threads \
802 ORDER BY updated_at DESC",
803 )?()
804 }
805
806 /// List all sidebar thread metadata, ordered by updated_at descending.
807 pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
808 self.select::<ThreadMetadata>(
809 "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order \
810 FROM sidebar_threads \
811 ORDER BY updated_at DESC"
812 )?()
813 }
814
815 /// Upsert metadata for a thread.
816 pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
817 let id = row.session_id.0.clone();
818 let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
819 None
820 } else {
821 Some(row.agent_id.to_string())
822 };
823 let title = row.title.to_string();
824 let updated_at = row.updated_at.to_rfc3339();
825 let created_at = row.created_at.map(|dt| dt.to_rfc3339());
826 let serialized = row.folder_paths.serialize();
827 let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
828 (None, None)
829 } else {
830 (Some(serialized.paths), Some(serialized.order))
831 };
832 let main_serialized = row.main_worktree_paths.serialize();
833 let (main_worktree_paths, main_worktree_paths_order) = if row.main_worktree_paths.is_empty()
834 {
835 (None, None)
836 } else {
837 (Some(main_serialized.paths), Some(main_serialized.order))
838 };
839 let archived = row.archived;
840
841 self.write(move |conn| {
842 let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order) \
843 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
844 ON CONFLICT(session_id) DO UPDATE SET \
845 agent_id = excluded.agent_id, \
846 title = excluded.title, \
847 updated_at = excluded.updated_at, \
848 created_at = excluded.created_at, \
849 folder_paths = excluded.folder_paths, \
850 folder_paths_order = excluded.folder_paths_order, \
851 archived = excluded.archived, \
852 main_worktree_paths = excluded.main_worktree_paths, \
853 main_worktree_paths_order = excluded.main_worktree_paths_order";
854 let mut stmt = Statement::prepare(conn, sql)?;
855 let mut i = stmt.bind(&id, 1)?;
856 i = stmt.bind(&agent_id, i)?;
857 i = stmt.bind(&title, i)?;
858 i = stmt.bind(&updated_at, i)?;
859 i = stmt.bind(&created_at, i)?;
860 i = stmt.bind(&folder_paths, i)?;
861 i = stmt.bind(&folder_paths_order, i)?;
862 i = stmt.bind(&archived, i)?;
863 i = stmt.bind(&main_worktree_paths, i)?;
864 stmt.bind(&main_worktree_paths_order, i)?;
865 stmt.exec()
866 })
867 .await
868 }
869
870 pub async fn create_archived_worktree(
871 &self,
872 worktree_path: &str,
873 main_repo_path: &str,
874 branch_name: Option<&str>,
875 commit_hash: &str,
876 ) -> anyhow::Result<i64> {
877 let worktree_path = worktree_path.to_string();
878 let main_repo_path = main_repo_path.to_string();
879 let branch_name = branch_name.map(|s| s.to_string());
880 let commit_hash = commit_hash.to_string();
881 self.write(move |conn| {
882 let id: i64 =
883 conn.select_row_bound::<_, i64>(sql!(
884 INSERT INTO archived_git_worktrees(
885 worktree_path, main_repo_path, branch_name, commit_hash
886 ) VALUES (?1, ?2, ?3, ?4)
887 RETURNING id
888 ))?((worktree_path, main_repo_path, branch_name, commit_hash))?
889 .context("Could not retrieve inserted archived worktree id")?;
890 Ok(id)
891 })
892 .await
893 }
894
895 pub async fn link_thread_to_archived_worktree(
896 &self,
897 session_id: &str,
898 archived_worktree_id: i64,
899 ) -> anyhow::Result<()> {
900 let session_id = session_id.to_string();
901 self.write(move |conn| {
902 let mut stmt = Statement::prepare(
903 conn,
904 "INSERT INTO thread_archived_worktrees(\
905 session_id, archived_worktree_id\
906 ) VALUES (?, ?)",
907 )?;
908 let i = stmt.bind(&session_id, 1)?;
909 stmt.bind(&archived_worktree_id, i)?;
910 stmt.exec()
911 })
912 .await
913 }
914
915 pub async fn get_archived_worktrees_for_thread(
916 &self,
917 session_id: &str,
918 ) -> anyhow::Result<Vec<ArchivedGitWorktree>> {
919 let session_id = session_id.to_string();
920 self.select_bound(
921 "SELECT aw.id, aw.worktree_path, aw.main_repo_path, aw.branch_name, aw.commit_hash, aw.restored \
922 FROM archived_git_worktrees aw \
923 JOIN thread_archived_worktrees taw ON taw.archived_worktree_id = aw.id \
924 WHERE taw.session_id = ?",
925 )?(session_id)
926 }
927
928 pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> {
929 self.write(move |conn| {
930 let mut stmt = Statement::prepare(
931 conn,
932 "DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?",
933 )?;
934 stmt.bind(&id, 1)?;
935 stmt.exec()?;
936
937 let mut stmt =
938 Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?;
939 stmt.bind(&id, 1)?;
940 stmt.exec()
941 })
942 .await
943 }
944
945 pub async fn set_archived_worktree_restored(
946 &self,
947 id: i64,
948 worktree_path: &str,
949 branch_name: Option<&str>,
950 ) -> anyhow::Result<()> {
951 let worktree_path = worktree_path.to_string();
952 let branch_name = branch_name.map(|s| s.to_string());
953 self.write(move |conn| {
954 let mut stmt = Statement::prepare(
955 conn,
956 "UPDATE archived_git_worktrees \
957 SET restored = 1, worktree_path = ?, branch_name = ? \
958 WHERE id = ?",
959 )?;
960 let mut i = stmt.bind(&worktree_path, 1)?;
961 i = stmt.bind(&branch_name, i)?;
962 stmt.bind(&id, i)?;
963 stmt.exec()
964 })
965 .await
966 }
967
968 /// Delete metadata for a single thread.
969 pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
970 let id = session_id.0.clone();
971 self.write(move |conn| {
972 let mut stmt =
973 Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
974 stmt.bind(&id, 1)?;
975 stmt.exec()
976 })
977 .await
978 }
979}
980
981impl Column for ThreadMetadata {
982 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
983 let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
984 let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
985 let (title, next): (String, i32) = Column::column(statement, next)?;
986 let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
987 let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
988 let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
989 let (folder_paths_order_str, next): (Option<String>, i32) =
990 Column::column(statement, next)?;
991 let (archived, next): (bool, i32) = Column::column(statement, next)?;
992 let (main_worktree_paths_str, next): (Option<String>, i32) =
993 Column::column(statement, next)?;
994 let (main_worktree_paths_order_str, next): (Option<String>, i32) =
995 Column::column(statement, next)?;
996
997 let agent_id = agent_id
998 .map(|id| AgentId::new(id))
999 .unwrap_or(ZED_AGENT_ID.clone());
1000
1001 let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
1002 let created_at = created_at_str
1003 .as_deref()
1004 .map(DateTime::parse_from_rfc3339)
1005 .transpose()?
1006 .map(|dt| dt.with_timezone(&Utc));
1007
1008 let folder_paths = folder_paths_str
1009 .map(|paths| {
1010 PathList::deserialize(&util::path_list::SerializedPathList {
1011 paths,
1012 order: folder_paths_order_str.unwrap_or_default(),
1013 })
1014 })
1015 .unwrap_or_default();
1016
1017 let main_worktree_paths = main_worktree_paths_str
1018 .map(|paths| {
1019 PathList::deserialize(&util::path_list::SerializedPathList {
1020 paths,
1021 order: main_worktree_paths_order_str.unwrap_or_default(),
1022 })
1023 })
1024 .unwrap_or_default();
1025
1026 Ok((
1027 ThreadMetadata {
1028 session_id: acp::SessionId::new(id),
1029 agent_id,
1030 title: title.into(),
1031 updated_at,
1032 created_at,
1033 folder_paths,
1034 main_worktree_paths,
1035 archived,
1036 pending_worktree_restore: None,
1037 },
1038 next,
1039 ))
1040 }
1041}
1042
1043impl Column for ArchivedGitWorktree {
1044 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1045 let (id, next): (i64, i32) = Column::column(statement, start_index)?;
1046 let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?;
1047 let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?;
1048 let (branch_name, next): (Option<String>, i32) = Column::column(statement, next)?;
1049 let (commit_hash, next): (String, i32) = Column::column(statement, next)?;
1050 let (restored_int, next): (i64, i32) = Column::column(statement, next)?;
1051 Ok((
1052 ArchivedGitWorktree {
1053 id,
1054 worktree_path: PathBuf::from(worktree_path_str),
1055 main_repo_path: PathBuf::from(main_repo_path_str),
1056 branch_name,
1057 commit_hash,
1058 restored: restored_int != 0,
1059 },
1060 next,
1061 ))
1062 }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068 use acp_thread::{AgentConnection, StubAgentConnection};
1069 use action_log::ActionLog;
1070 use agent::DbThread;
1071 use agent_client_protocol as acp;
1072 use feature_flags::FeatureFlagAppExt;
1073 use gpui::TestAppContext;
1074 use project::FakeFs;
1075 use project::Project;
1076 use std::path::Path;
1077 use std::rc::Rc;
1078
1079 fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
1080 DbThread {
1081 title: title.to_string().into(),
1082 messages: Vec::new(),
1083 updated_at,
1084 detailed_summary: None,
1085 initial_project_snapshot: None,
1086 cumulative_token_usage: Default::default(),
1087 request_token_usage: Default::default(),
1088 model: None,
1089 profile: None,
1090 imported: false,
1091 subagent_context: None,
1092 speed: None,
1093 thinking_enabled: false,
1094 thinking_effort: None,
1095 draft_prompt: None,
1096 ui_scroll_position: None,
1097 }
1098 }
1099
1100 fn make_metadata(
1101 session_id: &str,
1102 title: &str,
1103 updated_at: DateTime<Utc>,
1104 folder_paths: PathList,
1105 ) -> ThreadMetadata {
1106 ThreadMetadata {
1107 archived: false,
1108 session_id: acp::SessionId::new(session_id),
1109 agent_id: agent::ZED_AGENT_ID.clone(),
1110 title: title.to_string().into(),
1111 updated_at,
1112 created_at: Some(updated_at),
1113 folder_paths,
1114 main_worktree_paths: PathList::default(),
1115 pending_worktree_restore: None,
1116 }
1117 }
1118
1119 fn init_test(cx: &mut TestAppContext) {
1120 cx.update(|cx| {
1121 let settings_store = settings::SettingsStore::test(cx);
1122 cx.set_global(settings_store);
1123 cx.update_flags(true, vec!["agent-v2".to_string()]);
1124 ThreadMetadataStore::init_global(cx);
1125 ThreadStore::init_global(cx);
1126 });
1127 cx.run_until_parked();
1128 }
1129
1130 #[gpui::test]
1131 async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
1132 let first_paths = PathList::new(&[Path::new("/project-a")]);
1133 let second_paths = PathList::new(&[Path::new("/project-b")]);
1134 let now = Utc::now();
1135 let older = now - chrono::Duration::seconds(1);
1136
1137 let thread = std::thread::current();
1138 let test_name = thread.name().unwrap_or("unknown_test");
1139 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
1140 let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
1141 &db_name,
1142 )));
1143
1144 db.save(make_metadata(
1145 "session-1",
1146 "First Thread",
1147 now,
1148 first_paths.clone(),
1149 ))
1150 .await
1151 .unwrap();
1152 db.save(make_metadata(
1153 "session-2",
1154 "Second Thread",
1155 older,
1156 second_paths.clone(),
1157 ))
1158 .await
1159 .unwrap();
1160
1161 cx.update(|cx| {
1162 let settings_store = settings::SettingsStore::test(cx);
1163 cx.set_global(settings_store);
1164 cx.update_flags(true, vec!["agent-v2".to_string()]);
1165 ThreadMetadataStore::init_global(cx);
1166 });
1167
1168 cx.run_until_parked();
1169
1170 cx.update(|cx| {
1171 let store = ThreadMetadataStore::global(cx);
1172 let store = store.read(cx);
1173
1174 let entry_ids = store
1175 .entry_ids()
1176 .map(|session_id| session_id.0.to_string())
1177 .collect::<Vec<_>>();
1178 assert_eq!(entry_ids.len(), 2);
1179 assert!(entry_ids.contains(&"session-1".to_string()));
1180 assert!(entry_ids.contains(&"session-2".to_string()));
1181
1182 let first_path_entries = store
1183 .entries_for_path(&first_paths)
1184 .map(|entry| entry.session_id.0.to_string())
1185 .collect::<Vec<_>>();
1186 assert_eq!(first_path_entries, vec!["session-1"]);
1187
1188 let second_path_entries = store
1189 .entries_for_path(&second_paths)
1190 .map(|entry| entry.session_id.0.to_string())
1191 .collect::<Vec<_>>();
1192 assert_eq!(second_path_entries, vec!["session-2"]);
1193 });
1194 }
1195
1196 #[gpui::test]
1197 async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
1198 init_test(cx);
1199
1200 let first_paths = PathList::new(&[Path::new("/project-a")]);
1201 let second_paths = PathList::new(&[Path::new("/project-b")]);
1202 let initial_time = Utc::now();
1203 let updated_time = initial_time + chrono::Duration::seconds(1);
1204
1205 let initial_metadata = make_metadata(
1206 "session-1",
1207 "First Thread",
1208 initial_time,
1209 first_paths.clone(),
1210 );
1211
1212 let second_metadata = make_metadata(
1213 "session-2",
1214 "Second Thread",
1215 initial_time,
1216 second_paths.clone(),
1217 );
1218
1219 cx.update(|cx| {
1220 let store = ThreadMetadataStore::global(cx);
1221 store.update(cx, |store, cx| {
1222 store.save(initial_metadata, cx);
1223 store.save(second_metadata, cx);
1224 });
1225 });
1226
1227 cx.run_until_parked();
1228
1229 cx.update(|cx| {
1230 let store = ThreadMetadataStore::global(cx);
1231 let store = store.read(cx);
1232
1233 let first_path_entries = store
1234 .entries_for_path(&first_paths)
1235 .map(|entry| entry.session_id.0.to_string())
1236 .collect::<Vec<_>>();
1237 assert_eq!(first_path_entries, vec!["session-1"]);
1238
1239 let second_path_entries = store
1240 .entries_for_path(&second_paths)
1241 .map(|entry| entry.session_id.0.to_string())
1242 .collect::<Vec<_>>();
1243 assert_eq!(second_path_entries, vec!["session-2"]);
1244 });
1245
1246 let moved_metadata = make_metadata(
1247 "session-1",
1248 "First Thread",
1249 updated_time,
1250 second_paths.clone(),
1251 );
1252
1253 cx.update(|cx| {
1254 let store = ThreadMetadataStore::global(cx);
1255 store.update(cx, |store, cx| {
1256 store.save(moved_metadata, cx);
1257 });
1258 });
1259
1260 cx.run_until_parked();
1261
1262 cx.update(|cx| {
1263 let store = ThreadMetadataStore::global(cx);
1264 let store = store.read(cx);
1265
1266 let entry_ids = store
1267 .entry_ids()
1268 .map(|session_id| session_id.0.to_string())
1269 .collect::<Vec<_>>();
1270 assert_eq!(entry_ids.len(), 2);
1271 assert!(entry_ids.contains(&"session-1".to_string()));
1272 assert!(entry_ids.contains(&"session-2".to_string()));
1273
1274 let first_path_entries = store
1275 .entries_for_path(&first_paths)
1276 .map(|entry| entry.session_id.0.to_string())
1277 .collect::<Vec<_>>();
1278 assert!(first_path_entries.is_empty());
1279
1280 let second_path_entries = store
1281 .entries_for_path(&second_paths)
1282 .map(|entry| entry.session_id.0.to_string())
1283 .collect::<Vec<_>>();
1284 assert_eq!(second_path_entries.len(), 2);
1285 assert!(second_path_entries.contains(&"session-1".to_string()));
1286 assert!(second_path_entries.contains(&"session-2".to_string()));
1287 });
1288
1289 cx.update(|cx| {
1290 let store = ThreadMetadataStore::global(cx);
1291 store.update(cx, |store, cx| {
1292 store.delete(acp::SessionId::new("session-2"), cx);
1293 });
1294 });
1295
1296 cx.run_until_parked();
1297
1298 cx.update(|cx| {
1299 let store = ThreadMetadataStore::global(cx);
1300 let store = store.read(cx);
1301
1302 let entry_ids = store
1303 .entry_ids()
1304 .map(|session_id| session_id.0.to_string())
1305 .collect::<Vec<_>>();
1306 assert_eq!(entry_ids, vec!["session-1"]);
1307
1308 let second_path_entries = store
1309 .entries_for_path(&second_paths)
1310 .map(|entry| entry.session_id.0.to_string())
1311 .collect::<Vec<_>>();
1312 assert_eq!(second_path_entries, vec!["session-1"]);
1313 });
1314 }
1315
1316 #[gpui::test]
1317 async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
1318 init_test(cx);
1319
1320 let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1321 let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1322 let now = Utc::now();
1323
1324 let existing_metadata = ThreadMetadata {
1325 session_id: acp::SessionId::new("a-session-0"),
1326 agent_id: agent::ZED_AGENT_ID.clone(),
1327 title: "Existing Metadata".into(),
1328 updated_at: now - chrono::Duration::seconds(10),
1329 created_at: Some(now - chrono::Duration::seconds(10)),
1330 folder_paths: project_a_paths.clone(),
1331 main_worktree_paths: PathList::default(),
1332 archived: false,
1333 pending_worktree_restore: None,
1334 };
1335
1336 cx.update(|cx| {
1337 let store = ThreadMetadataStore::global(cx);
1338 store.update(cx, |store, cx| {
1339 store.save(existing_metadata, cx);
1340 });
1341 });
1342 cx.run_until_parked();
1343
1344 let threads_to_save = vec![
1345 (
1346 "a-session-0",
1347 "Thread A0 From Native Store",
1348 project_a_paths.clone(),
1349 now,
1350 ),
1351 (
1352 "a-session-1",
1353 "Thread A1",
1354 project_a_paths.clone(),
1355 now + chrono::Duration::seconds(1),
1356 ),
1357 (
1358 "b-session-0",
1359 "Thread B0",
1360 project_b_paths.clone(),
1361 now + chrono::Duration::seconds(2),
1362 ),
1363 (
1364 "projectless",
1365 "Projectless",
1366 PathList::default(),
1367 now + chrono::Duration::seconds(3),
1368 ),
1369 ];
1370
1371 for (session_id, title, paths, updated_at) in &threads_to_save {
1372 let save_task = cx.update(|cx| {
1373 let thread_store = ThreadStore::global(cx);
1374 let session_id = session_id.to_string();
1375 let title = title.to_string();
1376 let paths = paths.clone();
1377 thread_store.update(cx, |store, cx| {
1378 store.save_thread(
1379 acp::SessionId::new(session_id),
1380 make_db_thread(&title, *updated_at),
1381 paths,
1382 cx,
1383 )
1384 })
1385 });
1386 save_task.await.unwrap();
1387 cx.run_until_parked();
1388 }
1389
1390 cx.update(|cx| migrate_thread_metadata(cx));
1391 cx.run_until_parked();
1392
1393 let list = cx.update(|cx| {
1394 let store = ThreadMetadataStore::global(cx);
1395 store.read(cx).entries().cloned().collect::<Vec<_>>()
1396 });
1397
1398 assert_eq!(list.len(), 4);
1399 assert!(
1400 list.iter()
1401 .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
1402 );
1403
1404 let existing_metadata = list
1405 .iter()
1406 .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0")
1407 .unwrap();
1408 assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata");
1409 assert!(!existing_metadata.archived);
1410
1411 let migrated_session_ids = list
1412 .iter()
1413 .map(|metadata| metadata.session_id.0.as_ref())
1414 .collect::<Vec<_>>();
1415 assert!(migrated_session_ids.contains(&"a-session-1"));
1416 assert!(migrated_session_ids.contains(&"b-session-0"));
1417 assert!(migrated_session_ids.contains(&"projectless"));
1418
1419 let migrated_entries = list
1420 .iter()
1421 .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
1422 .collect::<Vec<_>>();
1423 assert!(migrated_entries.iter().all(|metadata| metadata.archived));
1424 }
1425
1426 #[gpui::test]
1427 async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
1428 cx: &mut TestAppContext,
1429 ) {
1430 init_test(cx);
1431
1432 let project_paths = PathList::new(&[Path::new("/project-a")]);
1433 let existing_updated_at = Utc::now();
1434
1435 let existing_metadata = ThreadMetadata {
1436 session_id: acp::SessionId::new("existing-session"),
1437 agent_id: agent::ZED_AGENT_ID.clone(),
1438 title: "Existing Metadata".into(),
1439 updated_at: existing_updated_at,
1440 created_at: Some(existing_updated_at),
1441 folder_paths: project_paths.clone(),
1442 main_worktree_paths: PathList::default(),
1443 archived: false,
1444 pending_worktree_restore: None,
1445 };
1446
1447 cx.update(|cx| {
1448 let store = ThreadMetadataStore::global(cx);
1449 store.update(cx, |store, cx| {
1450 store.save(existing_metadata, cx);
1451 });
1452 });
1453 cx.run_until_parked();
1454
1455 let save_task = cx.update(|cx| {
1456 let thread_store = ThreadStore::global(cx);
1457 thread_store.update(cx, |store, cx| {
1458 store.save_thread(
1459 acp::SessionId::new("existing-session"),
1460 make_db_thread(
1461 "Updated Native Thread Title",
1462 existing_updated_at + chrono::Duration::seconds(1),
1463 ),
1464 project_paths.clone(),
1465 cx,
1466 )
1467 })
1468 });
1469 save_task.await.unwrap();
1470 cx.run_until_parked();
1471
1472 cx.update(|cx| migrate_thread_metadata(cx));
1473 cx.run_until_parked();
1474
1475 let list = cx.update(|cx| {
1476 let store = ThreadMetadataStore::global(cx);
1477 store.read(cx).entries().cloned().collect::<Vec<_>>()
1478 });
1479
1480 assert_eq!(list.len(), 1);
1481 assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
1482 }
1483
1484 #[gpui::test]
1485 async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
1486 cx: &mut TestAppContext,
1487 ) {
1488 init_test(cx);
1489
1490 let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1491 let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1492 let now = Utc::now();
1493
1494 // Create 7 threads for project A and 3 for project B
1495 let mut threads_to_save = Vec::new();
1496 for i in 0..7 {
1497 threads_to_save.push((
1498 format!("a-session-{i}"),
1499 format!("Thread A{i}"),
1500 project_a_paths.clone(),
1501 now + chrono::Duration::seconds(i as i64),
1502 ));
1503 }
1504 for i in 0..3 {
1505 threads_to_save.push((
1506 format!("b-session-{i}"),
1507 format!("Thread B{i}"),
1508 project_b_paths.clone(),
1509 now + chrono::Duration::seconds(i as i64),
1510 ));
1511 }
1512
1513 for (session_id, title, paths, updated_at) in &threads_to_save {
1514 let save_task = cx.update(|cx| {
1515 let thread_store = ThreadStore::global(cx);
1516 let session_id = session_id.to_string();
1517 let title = title.to_string();
1518 let paths = paths.clone();
1519 thread_store.update(cx, |store, cx| {
1520 store.save_thread(
1521 acp::SessionId::new(session_id),
1522 make_db_thread(&title, *updated_at),
1523 paths,
1524 cx,
1525 )
1526 })
1527 });
1528 save_task.await.unwrap();
1529 cx.run_until_parked();
1530 }
1531
1532 cx.update(|cx| migrate_thread_metadata(cx));
1533 cx.run_until_parked();
1534
1535 let list = cx.update(|cx| {
1536 let store = ThreadMetadataStore::global(cx);
1537 store.read(cx).entries().cloned().collect::<Vec<_>>()
1538 });
1539
1540 assert_eq!(list.len(), 10);
1541
1542 // Project A: 5 most recent should be unarchived, 2 oldest should be archived
1543 let mut project_a_entries: Vec<_> = list
1544 .iter()
1545 .filter(|m| m.folder_paths == project_a_paths)
1546 .collect();
1547 assert_eq!(project_a_entries.len(), 7);
1548 project_a_entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1549
1550 for entry in &project_a_entries[..5] {
1551 assert!(
1552 !entry.archived,
1553 "Expected {} to be unarchived (top 5 most recent)",
1554 entry.session_id.0
1555 );
1556 }
1557 for entry in &project_a_entries[5..] {
1558 assert!(
1559 entry.archived,
1560 "Expected {} to be archived (older than top 5)",
1561 entry.session_id.0
1562 );
1563 }
1564
1565 // Project B: all 3 should be unarchived (under the limit)
1566 let project_b_entries: Vec<_> = list
1567 .iter()
1568 .filter(|m| m.folder_paths == project_b_paths)
1569 .collect();
1570 assert_eq!(project_b_entries.len(), 3);
1571 assert!(project_b_entries.iter().all(|m| !m.archived));
1572 }
1573
1574 #[gpui::test]
1575 async fn test_empty_thread_events_do_not_create_metadata(cx: &mut TestAppContext) {
1576 init_test(cx);
1577
1578 let fs = FakeFs::new(cx.executor());
1579 let project = Project::test(fs, None::<&Path>, cx).await;
1580 let connection = Rc::new(StubAgentConnection::new());
1581
1582 let thread = cx
1583 .update(|cx| {
1584 connection
1585 .clone()
1586 .new_session(project.clone(), PathList::default(), cx)
1587 })
1588 .await
1589 .unwrap();
1590 let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1591
1592 cx.update(|cx| {
1593 thread.update(cx, |thread, cx| {
1594 thread.set_title("Draft Thread".into(), cx).detach();
1595 });
1596 });
1597 cx.run_until_parked();
1598
1599 let metadata_ids = cx.update(|cx| {
1600 ThreadMetadataStore::global(cx)
1601 .read(cx)
1602 .entry_ids()
1603 .collect::<Vec<_>>()
1604 });
1605 assert!(
1606 metadata_ids.is_empty(),
1607 "expected empty draft thread title updates to be ignored"
1608 );
1609
1610 cx.update(|cx| {
1611 thread.update(cx, |thread, cx| {
1612 thread.push_user_content_block(None, "Hello".into(), cx);
1613 });
1614 });
1615 cx.run_until_parked();
1616
1617 let metadata_ids = cx.update(|cx| {
1618 ThreadMetadataStore::global(cx)
1619 .read(cx)
1620 .entry_ids()
1621 .collect::<Vec<_>>()
1622 });
1623 assert_eq!(metadata_ids, vec![session_id]);
1624 }
1625
1626 #[gpui::test]
1627 async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
1628 init_test(cx);
1629
1630 let fs = FakeFs::new(cx.executor());
1631 let project = Project::test(fs, None::<&Path>, cx).await;
1632 let connection = Rc::new(StubAgentConnection::new());
1633
1634 let thread = cx
1635 .update(|cx| {
1636 connection
1637 .clone()
1638 .new_session(project.clone(), PathList::default(), cx)
1639 })
1640 .await
1641 .unwrap();
1642 let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1643
1644 cx.update(|cx| {
1645 thread.update(cx, |thread, cx| {
1646 thread.push_user_content_block(None, "Hello".into(), cx);
1647 });
1648 });
1649 cx.run_until_parked();
1650
1651 let metadata_ids = cx.update(|cx| {
1652 ThreadMetadataStore::global(cx)
1653 .read(cx)
1654 .entry_ids()
1655 .collect::<Vec<_>>()
1656 });
1657 assert_eq!(metadata_ids, vec![session_id.clone()]);
1658
1659 drop(thread);
1660 cx.update(|_| {});
1661 cx.run_until_parked();
1662
1663 let metadata_ids = cx.update(|cx| {
1664 ThreadMetadataStore::global(cx)
1665 .read(cx)
1666 .entry_ids()
1667 .collect::<Vec<_>>()
1668 });
1669 assert_eq!(metadata_ids, vec![session_id]);
1670 }
1671
1672 #[gpui::test]
1673 async fn test_threads_without_project_association_are_archived_by_default(
1674 cx: &mut TestAppContext,
1675 ) {
1676 init_test(cx);
1677
1678 let fs = FakeFs::new(cx.executor());
1679 let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
1680 let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
1681 let connection = Rc::new(StubAgentConnection::new());
1682
1683 let thread_without_worktree = cx
1684 .update(|cx| {
1685 connection.clone().new_session(
1686 project_without_worktree.clone(),
1687 PathList::default(),
1688 cx,
1689 )
1690 })
1691 .await
1692 .unwrap();
1693 let session_without_worktree =
1694 cx.read(|cx| thread_without_worktree.read(cx).session_id().clone());
1695
1696 cx.update(|cx| {
1697 thread_without_worktree.update(cx, |thread, cx| {
1698 thread.push_user_content_block(None, "content".into(), cx);
1699 thread.set_title("No Project Thread".into(), cx).detach();
1700 });
1701 });
1702 cx.run_until_parked();
1703
1704 let thread_with_worktree = cx
1705 .update(|cx| {
1706 connection.clone().new_session(
1707 project_with_worktree.clone(),
1708 PathList::default(),
1709 cx,
1710 )
1711 })
1712 .await
1713 .unwrap();
1714 let session_with_worktree =
1715 cx.read(|cx| thread_with_worktree.read(cx).session_id().clone());
1716
1717 cx.update(|cx| {
1718 thread_with_worktree.update(cx, |thread, cx| {
1719 thread.push_user_content_block(None, "content".into(), cx);
1720 thread.set_title("Project Thread".into(), cx).detach();
1721 });
1722 });
1723 cx.run_until_parked();
1724
1725 cx.update(|cx| {
1726 let store = ThreadMetadataStore::global(cx);
1727 let store = store.read(cx);
1728
1729 let without_worktree = store
1730 .entry(&session_without_worktree)
1731 .expect("missing metadata for thread without project association");
1732 assert!(without_worktree.folder_paths.is_empty());
1733 assert!(
1734 without_worktree.archived,
1735 "expected thread without project association to be archived"
1736 );
1737
1738 let with_worktree = store
1739 .entry(&session_with_worktree)
1740 .expect("missing metadata for thread with project association");
1741 assert_eq!(
1742 with_worktree.folder_paths,
1743 PathList::new(&[Path::new("/project-a")])
1744 );
1745 assert!(
1746 !with_worktree.archived,
1747 "expected thread with project association to remain unarchived"
1748 );
1749 });
1750 }
1751
1752 #[gpui::test]
1753 async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
1754 init_test(cx);
1755
1756 let fs = FakeFs::new(cx.executor());
1757 let project = Project::test(fs, None::<&Path>, cx).await;
1758 let connection = Rc::new(StubAgentConnection::new());
1759
1760 // Create a regular (non-subagent) AcpThread.
1761 let regular_thread = cx
1762 .update(|cx| {
1763 connection
1764 .clone()
1765 .new_session(project.clone(), PathList::default(), cx)
1766 })
1767 .await
1768 .unwrap();
1769
1770 let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
1771
1772 // Set a title on the regular thread to trigger a save via handle_thread_update.
1773 cx.update(|cx| {
1774 regular_thread.update(cx, |thread, cx| {
1775 thread.push_user_content_block(None, "content".into(), cx);
1776 thread.set_title("Regular Thread".into(), cx).detach();
1777 });
1778 });
1779 cx.run_until_parked();
1780
1781 // Create a subagent AcpThread
1782 let subagent_session_id = acp::SessionId::new("subagent-session");
1783 let subagent_thread = cx.update(|cx| {
1784 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1785 cx.new(|cx| {
1786 acp_thread::AcpThread::new(
1787 Some(regular_session_id.clone()),
1788 Some("Subagent Thread".into()),
1789 None,
1790 connection.clone(),
1791 project.clone(),
1792 action_log,
1793 subagent_session_id.clone(),
1794 watch::Receiver::constant(acp::PromptCapabilities::new()),
1795 cx,
1796 )
1797 })
1798 });
1799
1800 // Set a title on the subagent thread to trigger handle_thread_update.
1801 cx.update(|cx| {
1802 subagent_thread.update(cx, |thread, cx| {
1803 thread
1804 .set_title("Subagent Thread Title".into(), cx)
1805 .detach();
1806 });
1807 });
1808 cx.run_until_parked();
1809
1810 // List all metadata from the store cache.
1811 let list = cx.update(|cx| {
1812 let store = ThreadMetadataStore::global(cx);
1813 store.read(cx).entries().cloned().collect::<Vec<_>>()
1814 });
1815
1816 // The subagent thread should NOT appear in the sidebar metadata.
1817 // Only the regular thread should be listed.
1818 assert_eq!(
1819 list.len(),
1820 1,
1821 "Expected only the regular thread in sidebar metadata, \
1822 but found {} entries (subagent threads are leaking into the sidebar)",
1823 list.len(),
1824 );
1825 assert_eq!(list[0].session_id, regular_session_id);
1826 assert_eq!(list[0].title.as_ref(), "Regular Thread");
1827 }
1828
1829 #[test]
1830 fn test_dedup_db_operations_keeps_latest_operation_for_session() {
1831 let now = Utc::now();
1832
1833 let operations = vec![
1834 DbOperation::Upsert(make_metadata(
1835 "session-1",
1836 "First Thread",
1837 now,
1838 PathList::default(),
1839 )),
1840 DbOperation::Delete(acp::SessionId::new("session-1")),
1841 ];
1842
1843 let deduped = ThreadMetadataStore::dedup_db_operations(operations);
1844
1845 assert_eq!(deduped.len(), 1);
1846 assert_eq!(
1847 deduped[0],
1848 DbOperation::Delete(acp::SessionId::new("session-1"))
1849 );
1850 }
1851
1852 #[test]
1853 fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
1854 let now = Utc::now();
1855 let later = now + chrono::Duration::seconds(1);
1856
1857 let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
1858 let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
1859
1860 let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1861 DbOperation::Upsert(old_metadata),
1862 DbOperation::Upsert(new_metadata.clone()),
1863 ]);
1864
1865 assert_eq!(deduped.len(), 1);
1866 assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
1867 }
1868
1869 #[test]
1870 fn test_dedup_db_operations_preserves_distinct_sessions() {
1871 let now = Utc::now();
1872
1873 let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
1874 let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
1875 let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1876 DbOperation::Upsert(metadata1.clone()),
1877 DbOperation::Upsert(metadata2.clone()),
1878 ]);
1879
1880 assert_eq!(deduped.len(), 2);
1881 assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
1882 assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
1883 }
1884
1885 #[gpui::test]
1886 async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
1887 init_test(cx);
1888
1889 let paths = PathList::new(&[Path::new("/project-a")]);
1890 let now = Utc::now();
1891 let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
1892
1893 cx.update(|cx| {
1894 let store = ThreadMetadataStore::global(cx);
1895 store.update(cx, |store, cx| {
1896 store.save(metadata, cx);
1897 });
1898 });
1899
1900 cx.run_until_parked();
1901
1902 cx.update(|cx| {
1903 let store = ThreadMetadataStore::global(cx);
1904 let store = store.read(cx);
1905
1906 let path_entries = store
1907 .entries_for_path(&paths)
1908 .map(|e| e.session_id.0.to_string())
1909 .collect::<Vec<_>>();
1910 assert_eq!(path_entries, vec!["session-1"]);
1911
1912 let archived = store
1913 .archived_entries()
1914 .map(|e| e.session_id.0.to_string())
1915 .collect::<Vec<_>>();
1916 assert!(archived.is_empty());
1917 });
1918
1919 cx.update(|cx| {
1920 let store = ThreadMetadataStore::global(cx);
1921 store.update(cx, |store, cx| {
1922 store.archive(&acp::SessionId::new("session-1"), cx);
1923 });
1924 });
1925
1926 cx.run_until_parked();
1927
1928 cx.update(|cx| {
1929 let store = ThreadMetadataStore::global(cx);
1930 let store = store.read(cx);
1931
1932 let path_entries = store
1933 .entries_for_path(&paths)
1934 .map(|e| e.session_id.0.to_string())
1935 .collect::<Vec<_>>();
1936 assert!(path_entries.is_empty());
1937
1938 let archived = store.archived_entries().collect::<Vec<_>>();
1939 assert_eq!(archived.len(), 1);
1940 assert_eq!(archived[0].session_id.0.as_ref(), "session-1");
1941 assert!(archived[0].archived);
1942 });
1943
1944 cx.update(|cx| {
1945 let store = ThreadMetadataStore::global(cx);
1946 store.update(cx, |store, cx| {
1947 store.unarchive(&acp::SessionId::new("session-1"), cx);
1948 });
1949 });
1950
1951 cx.run_until_parked();
1952
1953 cx.update(|cx| {
1954 let store = ThreadMetadataStore::global(cx);
1955 let store = store.read(cx);
1956
1957 let path_entries = store
1958 .entries_for_path(&paths)
1959 .map(|e| e.session_id.0.to_string())
1960 .collect::<Vec<_>>();
1961 assert_eq!(path_entries, vec!["session-1"]);
1962
1963 let archived = store
1964 .archived_entries()
1965 .map(|e| e.session_id.0.to_string())
1966 .collect::<Vec<_>>();
1967 assert!(archived.is_empty());
1968 });
1969 }
1970
1971 #[gpui::test]
1972 async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
1973 init_test(cx);
1974
1975 let paths = PathList::new(&[Path::new("/project-a")]);
1976 let now = Utc::now();
1977
1978 let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
1979 let metadata2 = make_metadata(
1980 "session-2",
1981 "Archived Thread",
1982 now - chrono::Duration::seconds(1),
1983 paths.clone(),
1984 );
1985
1986 cx.update(|cx| {
1987 let store = ThreadMetadataStore::global(cx);
1988 store.update(cx, |store, cx| {
1989 store.save(metadata1, cx);
1990 store.save(metadata2, cx);
1991 });
1992 });
1993
1994 cx.run_until_parked();
1995
1996 cx.update(|cx| {
1997 let store = ThreadMetadataStore::global(cx);
1998 store.update(cx, |store, cx| {
1999 store.archive(&acp::SessionId::new("session-2"), cx);
2000 });
2001 });
2002
2003 cx.run_until_parked();
2004
2005 cx.update(|cx| {
2006 let store = ThreadMetadataStore::global(cx);
2007 let store = store.read(cx);
2008
2009 let path_entries = store
2010 .entries_for_path(&paths)
2011 .map(|e| e.session_id.0.to_string())
2012 .collect::<Vec<_>>();
2013 assert_eq!(path_entries, vec!["session-1"]);
2014
2015 let all_entries = store
2016 .entries()
2017 .map(|e| e.session_id.0.to_string())
2018 .collect::<Vec<_>>();
2019 assert_eq!(all_entries.len(), 2);
2020 assert!(all_entries.contains(&"session-1".to_string()));
2021 assert!(all_entries.contains(&"session-2".to_string()));
2022
2023 let archived = store
2024 .archived_entries()
2025 .map(|e| e.session_id.0.to_string())
2026 .collect::<Vec<_>>();
2027 assert_eq!(archived, vec!["session-2"]);
2028 });
2029 }
2030
2031 #[gpui::test]
2032 async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
2033 init_test(cx);
2034
2035 let paths = PathList::new(&[Path::new("/project-a")]);
2036 let now = Utc::now();
2037
2038 let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
2039 let m2 = make_metadata(
2040 "session-2",
2041 "Thread Two",
2042 now - chrono::Duration::seconds(1),
2043 paths.clone(),
2044 );
2045 let m3 = make_metadata(
2046 "session-3",
2047 "Thread Three",
2048 now - chrono::Duration::seconds(2),
2049 paths,
2050 );
2051
2052 cx.update(|cx| {
2053 let store = ThreadMetadataStore::global(cx);
2054 store.update(cx, |store, cx| {
2055 store.save_all(vec![m1, m2, m3], cx);
2056 });
2057 });
2058
2059 cx.run_until_parked();
2060
2061 cx.update(|cx| {
2062 let store = ThreadMetadataStore::global(cx);
2063 let store = store.read(cx);
2064
2065 let all_entries = store
2066 .entries()
2067 .map(|e| e.session_id.0.to_string())
2068 .collect::<Vec<_>>();
2069 assert_eq!(all_entries.len(), 3);
2070 assert!(all_entries.contains(&"session-1".to_string()));
2071 assert!(all_entries.contains(&"session-2".to_string()));
2072 assert!(all_entries.contains(&"session-3".to_string()));
2073
2074 let entry_ids = store.entry_ids().collect::<Vec<_>>();
2075 assert_eq!(entry_ids.len(), 3);
2076 });
2077 }
2078
2079 #[gpui::test]
2080 async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
2081 init_test(cx);
2082
2083 let paths = PathList::new(&[Path::new("/project-a")]);
2084 let now = Utc::now();
2085 let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
2086
2087 cx.update(|cx| {
2088 let store = ThreadMetadataStore::global(cx);
2089 store.update(cx, |store, cx| {
2090 store.save(metadata, cx);
2091 });
2092 });
2093
2094 cx.run_until_parked();
2095
2096 cx.update(|cx| {
2097 let store = ThreadMetadataStore::global(cx);
2098 store.update(cx, |store, cx| {
2099 store.archive(&acp::SessionId::new("session-1"), cx);
2100 });
2101 });
2102
2103 cx.run_until_parked();
2104
2105 cx.update(|cx| {
2106 let store = ThreadMetadataStore::global(cx);
2107 store.update(cx, |store, cx| {
2108 let _ = store.reload(cx);
2109 });
2110 });
2111
2112 cx.run_until_parked();
2113
2114 cx.update(|cx| {
2115 let store = ThreadMetadataStore::global(cx);
2116 let store = store.read(cx);
2117
2118 let thread = store
2119 .entries()
2120 .find(|e| e.session_id.0.as_ref() == "session-1")
2121 .expect("thread should exist after reload");
2122 assert!(thread.archived);
2123
2124 let path_entries = store
2125 .entries_for_path(&paths)
2126 .map(|e| e.session_id.0.to_string())
2127 .collect::<Vec<_>>();
2128 assert!(path_entries.is_empty());
2129
2130 let archived = store
2131 .archived_entries()
2132 .map(|e| e.session_id.0.to_string())
2133 .collect::<Vec<_>>();
2134 assert_eq!(archived, vec!["session-1"]);
2135 });
2136 }
2137
2138 #[gpui::test]
2139 async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
2140 init_test(cx);
2141
2142 cx.run_until_parked();
2143
2144 cx.update(|cx| {
2145 let store = ThreadMetadataStore::global(cx);
2146 store.update(cx, |store, cx| {
2147 store.archive(&acp::SessionId::new("nonexistent"), cx);
2148 });
2149 });
2150
2151 cx.run_until_parked();
2152
2153 cx.update(|cx| {
2154 let store = ThreadMetadataStore::global(cx);
2155 let store = store.read(cx);
2156
2157 assert!(store.is_empty());
2158 assert_eq!(store.entries().count(), 0);
2159 assert_eq!(store.archived_entries().count(), 0);
2160 });
2161 }
2162
2163 #[gpui::test]
2164 async fn test_save_followed_by_archiving_without_parking(cx: &mut TestAppContext) {
2165 init_test(cx);
2166
2167 let paths = PathList::new(&[Path::new("/project-a")]);
2168 let now = Utc::now();
2169 let metadata = make_metadata("session-1", "Thread 1", now, paths);
2170 let session_id = metadata.session_id.clone();
2171
2172 cx.update(|cx| {
2173 let store = ThreadMetadataStore::global(cx);
2174 store.update(cx, |store, cx| {
2175 store.save(metadata.clone(), cx);
2176 store.archive(&session_id, cx);
2177 });
2178 });
2179
2180 cx.run_until_parked();
2181
2182 cx.update(|cx| {
2183 let store = ThreadMetadataStore::global(cx);
2184 let store = store.read(cx);
2185
2186 let entries: Vec<ThreadMetadata> = store.entries().cloned().collect();
2187 pretty_assertions::assert_eq!(
2188 entries,
2189 vec![ThreadMetadata {
2190 archived: true,
2191 ..metadata
2192 }]
2193 );
2194 });
2195 }
2196}