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 shown temporarily while restoration is pending.
138 /// This is runtime-only state — not persisted to the database.
139 pub pending_worktree_restore: Option<PathBuf>,
140}
141
142impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
143 fn from(meta: &ThreadMetadata) -> Self {
144 Self {
145 session_id: meta.session_id.clone(),
146 work_dirs: Some(meta.folder_paths.clone()),
147 title: Some(meta.title.clone()),
148 updated_at: Some(meta.updated_at),
149 created_at: meta.created_at,
150 meta: None,
151 }
152 }
153}
154
155/// Record of a git worktree that was archived (deleted from disk) when its
156/// last thread was archived.
157pub struct ArchivedGitWorktree {
158 /// Auto-incrementing primary key.
159 pub id: i64,
160 /// Absolute path to the worktree directory before deletion.
161 pub worktree_path: PathBuf,
162 /// Absolute path of the main repository that owned this worktree.
163 pub main_repo_path: PathBuf,
164 /// Branch checked out at archive time. None if detached HEAD.
165 pub branch_name: Option<String>,
166 /// SHA of the commit capturing the staged state at archive time.
167 pub staged_commit_hash: String,
168 /// SHA of the commit capturing the unstaged state at archive time.
169 pub unstaged_commit_hash: String,
170 /// Whether this worktree has been restored.
171 pub restored: bool,
172}
173
174/// The store holds all metadata needed to show threads in the sidebar/the archive.
175///
176/// Automatically listens to AcpThread events and updates metadata if it has changed.
177pub struct ThreadMetadataStore {
178 db: ThreadMetadataDb,
179 threads: HashMap<acp::SessionId, ThreadMetadata>,
180 threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
181 threads_by_main_paths: HashMap<PathList, HashSet<acp::SessionId>>,
182 reload_task: Option<Shared<Task<()>>>,
183 session_subscriptions: HashMap<acp::SessionId, Subscription>,
184 pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
185 _db_operations_task: Task<()>,
186}
187
188#[derive(Debug, PartialEq)]
189enum DbOperation {
190 Upsert(ThreadMetadata),
191 Delete(acp::SessionId),
192}
193
194impl DbOperation {
195 fn id(&self) -> &acp::SessionId {
196 match self {
197 DbOperation::Upsert(thread) => &thread.session_id,
198 DbOperation::Delete(session_id) => session_id,
199 }
200 }
201}
202
203impl ThreadMetadataStore {
204 #[cfg(not(any(test, feature = "test-support")))]
205 pub fn init_global(cx: &mut App) {
206 if cx.has_global::<Self>() {
207 return;
208 }
209
210 let db = ThreadMetadataDb::global(cx);
211 let thread_store = cx.new(|cx| Self::new(db, cx));
212 cx.set_global(GlobalThreadMetadataStore(thread_store));
213 }
214
215 #[cfg(any(test, feature = "test-support"))]
216 pub fn init_global(cx: &mut App) {
217 let thread = std::thread::current();
218 let test_name = thread.name().unwrap_or("unknown_test");
219 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
220 let db = smol::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
221 let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
222 cx.set_global(GlobalThreadMetadataStore(thread_store));
223 }
224
225 pub fn try_global(cx: &App) -> Option<Entity<Self>> {
226 cx.try_global::<GlobalThreadMetadataStore>()
227 .map(|store| store.0.clone())
228 }
229
230 pub fn global(cx: &App) -> Entity<Self> {
231 cx.global::<GlobalThreadMetadataStore>().0.clone()
232 }
233
234 pub fn is_empty(&self) -> bool {
235 self.threads.is_empty()
236 }
237
238 /// Returns all thread IDs.
239 pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
240 self.threads.keys().cloned()
241 }
242
243 /// Returns the metadata for a specific thread, if it exists.
244 pub fn entry(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> {
245 self.threads.get(session_id)
246 }
247
248 /// Returns all threads.
249 pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
250 self.threads.values()
251 }
252
253 /// Returns all archived threads.
254 pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
255 self.entries().filter(|t| t.archived)
256 }
257
258 /// Returns all threads for the given path list, excluding archived threads.
259 pub fn entries_for_path(
260 &self,
261 path_list: &PathList,
262 ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
263 self.threads_by_paths
264 .get(path_list)
265 .into_iter()
266 .flatten()
267 .filter_map(|s| self.threads.get(s))
268 .filter(|s| !s.archived)
269 }
270
271 /// Returns threads whose `main_worktree_paths` matches the given path list,
272 /// excluding archived threads. This finds threads that were opened in a
273 /// linked worktree but are associated with the given main worktree.
274 pub fn entries_for_main_worktree_path(
275 &self,
276 path_list: &PathList,
277 ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
278 self.threads_by_main_paths
279 .get(path_list)
280 .into_iter()
281 .flatten()
282 .filter_map(|s| self.threads.get(s))
283 .filter(|s| !s.archived)
284 }
285
286 fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
287 let db = self.db.clone();
288 self.reload_task.take();
289
290 let list_task = cx
291 .background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
292
293 let reload_task = cx
294 .spawn(async move |this, cx| {
295 let Some(rows) = list_task.await.log_err() else {
296 return;
297 };
298
299 this.update(cx, |this, cx| {
300 this.threads.clear();
301 this.threads_by_paths.clear();
302 this.threads_by_main_paths.clear();
303
304 for row in rows {
305 this.threads_by_paths
306 .entry(row.folder_paths.clone())
307 .or_default()
308 .insert(row.session_id.clone());
309 if !row.main_worktree_paths.is_empty() {
310 this.threads_by_main_paths
311 .entry(row.main_worktree_paths.clone())
312 .or_default()
313 .insert(row.session_id.clone());
314 }
315 this.threads.insert(row.session_id.clone(), row);
316 }
317
318 cx.notify();
319 })
320 .ok();
321 })
322 .shared();
323 self.reload_task = Some(reload_task.clone());
324 reload_task
325 }
326
327 pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
328 if !cx.has_flag::<AgentV2FeatureFlag>() {
329 return;
330 }
331
332 for metadata in metadata {
333 self.save_internal(metadata);
334 }
335 cx.notify();
336 }
337
338 #[cfg(any(test, feature = "test-support"))]
339 pub fn save_manually(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
340 self.save(metadata, cx)
341 }
342
343 fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
344 if !cx.has_flag::<AgentV2FeatureFlag>() {
345 return;
346 }
347
348 self.save_internal(metadata);
349 cx.notify();
350 }
351
352 fn save_internal(&mut self, metadata: ThreadMetadata) {
353 if let Some(thread) = self.threads.get(&metadata.session_id) {
354 if thread.folder_paths != metadata.folder_paths {
355 if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
356 session_ids.remove(&metadata.session_id);
357 }
358 }
359 if thread.main_worktree_paths != metadata.main_worktree_paths
360 && !thread.main_worktree_paths.is_empty()
361 {
362 if let Some(session_ids) = self
363 .threads_by_main_paths
364 .get_mut(&thread.main_worktree_paths)
365 {
366 session_ids.remove(&metadata.session_id);
367 }
368 }
369 }
370
371 self.threads
372 .insert(metadata.session_id.clone(), metadata.clone());
373
374 self.threads_by_paths
375 .entry(metadata.folder_paths.clone())
376 .or_default()
377 .insert(metadata.session_id.clone());
378
379 if !metadata.main_worktree_paths.is_empty() {
380 self.threads_by_main_paths
381 .entry(metadata.main_worktree_paths.clone())
382 .or_default()
383 .insert(metadata.session_id.clone());
384 }
385
386 self.pending_thread_ops_tx
387 .try_send(DbOperation::Upsert(metadata))
388 .log_err();
389 }
390
391 pub fn update_working_directories(
392 &mut self,
393 session_id: &acp::SessionId,
394 work_dirs: PathList,
395 cx: &mut Context<Self>,
396 ) {
397 if !cx.has_flag::<AgentV2FeatureFlag>() {
398 return;
399 }
400
401 if let Some(thread) = self.threads.get(session_id) {
402 self.save_internal(ThreadMetadata {
403 folder_paths: work_dirs,
404 ..thread.clone()
405 });
406 cx.notify();
407 }
408 }
409
410 pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
411 self.update_archived(session_id, true, cx);
412 }
413
414 pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
415 self.update_archived(session_id, false, cx);
416 }
417
418 pub fn set_pending_worktree_restore(
419 &mut self,
420 session_id: &acp::SessionId,
421 main_repo_path: Option<PathBuf>,
422 cx: &mut Context<Self>,
423 ) {
424 if let Some(thread) = self.threads.get_mut(session_id) {
425 thread.pending_worktree_restore = main_repo_path;
426 cx.notify();
427 }
428 }
429
430 pub fn complete_worktree_restore(
431 &mut self,
432 session_id: &acp::SessionId,
433 path_replacements: &[(PathBuf, PathBuf)],
434 cx: &mut Context<Self>,
435 ) {
436 if let Some(thread) = self.threads.get(session_id).cloned() {
437 let mut paths: Vec<PathBuf> = thread.folder_paths.paths().to_vec();
438 for (old_path, new_path) in path_replacements {
439 if let Some(pos) = paths.iter().position(|p| p == old_path) {
440 paths[pos] = new_path.clone();
441 }
442 }
443 let new_folder_paths = PathList::new(&paths);
444 self.save_internal(ThreadMetadata {
445 pending_worktree_restore: None,
446 folder_paths: new_folder_paths,
447 ..thread
448 });
449 cx.notify();
450 }
451 }
452
453 pub fn create_archived_worktree(
454 &self,
455 worktree_path: &str,
456 main_repo_path: &str,
457 branch_name: Option<&str>,
458 staged_commit_hash: &str,
459 unstaged_commit_hash: &str,
460 cx: &App,
461 ) -> Task<anyhow::Result<i64>> {
462 let db = self.db.clone();
463 let worktree_path = worktree_path.to_string();
464 let main_repo_path = main_repo_path.to_string();
465 let branch_name = branch_name.map(|s| s.to_string());
466 let staged_commit_hash = staged_commit_hash.to_string();
467 let unstaged_commit_hash = unstaged_commit_hash.to_string();
468 cx.background_spawn(async move {
469 db.create_archived_worktree(
470 &worktree_path,
471 &main_repo_path,
472 branch_name.as_deref(),
473 &staged_commit_hash,
474 &unstaged_commit_hash,
475 )
476 .await
477 })
478 }
479
480 pub fn link_thread_to_archived_worktree(
481 &self,
482 session_id: &str,
483 archived_worktree_id: i64,
484 cx: &App,
485 ) -> Task<anyhow::Result<()>> {
486 let db = self.db.clone();
487 let session_id = session_id.to_string();
488 cx.background_spawn(async move {
489 db.link_thread_to_archived_worktree(&session_id, archived_worktree_id)
490 .await
491 })
492 }
493
494 pub fn get_archived_worktrees_for_thread(
495 &self,
496 session_id: &str,
497 cx: &App,
498 ) -> Task<anyhow::Result<Vec<ArchivedGitWorktree>>> {
499 let db = self.db.clone();
500 let session_id = session_id.to_string();
501 cx.background_spawn(async move { db.get_archived_worktrees_for_thread(&session_id).await })
502 }
503
504 pub fn delete_archived_worktree(&self, id: i64, cx: &App) -> Task<anyhow::Result<()>> {
505 let db = self.db.clone();
506 cx.background_spawn(async move { db.delete_archived_worktree(id).await })
507 }
508
509 pub fn set_archived_worktree_restored(
510 &self,
511 id: i64,
512 worktree_path: &str,
513 branch_name: Option<&str>,
514 cx: &App,
515 ) -> Task<anyhow::Result<()>> {
516 let db = self.db.clone();
517 let worktree_path = worktree_path.to_string();
518 let branch_name = branch_name.map(|s| s.to_string());
519 cx.background_spawn(async move {
520 db.set_archived_worktree_restored(id, &worktree_path, branch_name.as_deref())
521 .await
522 })
523 }
524
525 pub fn all_session_ids_for_path<'a>(
526 &'a self,
527 path_list: &PathList,
528 ) -> impl Iterator<Item = &'a acp::SessionId> {
529 self.threads_by_paths
530 .get(path_list)
531 .into_iter()
532 .flat_map(|session_ids| session_ids.iter())
533 }
534
535 fn update_archived(
536 &mut self,
537 session_id: &acp::SessionId,
538 archived: bool,
539 cx: &mut Context<Self>,
540 ) {
541 if !cx.has_flag::<AgentV2FeatureFlag>() {
542 return;
543 }
544
545 if let Some(thread) = self.threads.get(session_id) {
546 self.save_internal(ThreadMetadata {
547 archived,
548 ..thread.clone()
549 });
550 cx.notify();
551 }
552 }
553
554 pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
555 if !cx.has_flag::<AgentV2FeatureFlag>() {
556 return;
557 }
558
559 if let Some(thread) = self.threads.get(&session_id) {
560 if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
561 session_ids.remove(&session_id);
562 }
563 if !thread.main_worktree_paths.is_empty() {
564 if let Some(session_ids) = self
565 .threads_by_main_paths
566 .get_mut(&thread.main_worktree_paths)
567 {
568 session_ids.remove(&session_id);
569 }
570 }
571 }
572 self.threads.remove(&session_id);
573 self.pending_thread_ops_tx
574 .try_send(DbOperation::Delete(session_id))
575 .log_err();
576 cx.notify();
577 }
578
579 fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
580 let weak_store = cx.weak_entity();
581
582 cx.observe_new::<acp_thread::AcpThread>(move |thread, _window, cx| {
583 // Don't track subagent threads in the sidebar.
584 if thread.parent_session_id().is_some() {
585 return;
586 }
587
588 let thread_entity = cx.entity();
589
590 cx.on_release({
591 let weak_store = weak_store.clone();
592 move |thread, cx| {
593 weak_store
594 .update(cx, |store, _cx| {
595 let session_id = thread.session_id().clone();
596 store.session_subscriptions.remove(&session_id);
597 })
598 .ok();
599 }
600 })
601 .detach();
602
603 weak_store
604 .update(cx, |this, cx| {
605 let subscription = cx.subscribe(&thread_entity, Self::handle_thread_event);
606 this.session_subscriptions
607 .insert(thread.session_id().clone(), subscription);
608 })
609 .ok();
610 })
611 .detach();
612
613 let (tx, rx) = smol::channel::unbounded();
614 let _db_operations_task = cx.background_spawn({
615 let db = db.clone();
616 async move {
617 while let Ok(first_update) = rx.recv().await {
618 let mut updates = vec![first_update];
619 while let Ok(update) = rx.try_recv() {
620 updates.push(update);
621 }
622 let updates = Self::dedup_db_operations(updates);
623 for operation in updates {
624 match operation {
625 DbOperation::Upsert(metadata) => {
626 db.save(metadata).await.log_err();
627 }
628 DbOperation::Delete(session_id) => {
629 db.delete(session_id).await.log_err();
630 }
631 }
632 }
633 }
634 }
635 });
636
637 let mut this = Self {
638 db,
639 threads: HashMap::default(),
640 threads_by_paths: HashMap::default(),
641 threads_by_main_paths: HashMap::default(),
642 reload_task: None,
643 session_subscriptions: HashMap::default(),
644 pending_thread_ops_tx: tx,
645 _db_operations_task,
646 };
647 let _ = this.reload(cx);
648 this
649 }
650
651 fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
652 let mut ops = HashMap::default();
653 for operation in operations.into_iter().rev() {
654 if ops.contains_key(operation.id()) {
655 continue;
656 }
657 ops.insert(operation.id().clone(), operation);
658 }
659 ops.into_values().collect()
660 }
661
662 fn handle_thread_event(
663 &mut self,
664 thread: Entity<acp_thread::AcpThread>,
665 event: &AcpThreadEvent,
666 cx: &mut Context<Self>,
667 ) {
668 // Don't track subagent threads in the sidebar.
669 if thread.read(cx).parent_session_id().is_some() {
670 return;
671 }
672
673 match event {
674 AcpThreadEvent::NewEntry
675 | AcpThreadEvent::TitleUpdated
676 | AcpThreadEvent::EntryUpdated(_)
677 | AcpThreadEvent::EntriesRemoved(_)
678 | AcpThreadEvent::ToolAuthorizationRequested(_)
679 | AcpThreadEvent::ToolAuthorizationReceived(_)
680 | AcpThreadEvent::Retry(_)
681 | AcpThreadEvent::Stopped(_)
682 | AcpThreadEvent::Error
683 | AcpThreadEvent::LoadError(_)
684 | AcpThreadEvent::Refusal
685 | AcpThreadEvent::WorkingDirectoriesUpdated => {
686 let thread_ref = thread.read(cx);
687 if thread_ref.entries().is_empty() {
688 return;
689 }
690
691 let existing_thread = self.threads.get(thread_ref.session_id());
692 let session_id = thread_ref.session_id().clone();
693 let title = thread_ref
694 .title()
695 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
696
697 let updated_at = Utc::now();
698
699 let created_at = existing_thread
700 .and_then(|t| t.created_at)
701 .unwrap_or_else(|| updated_at);
702
703 let agent_id = thread_ref.connection().agent_id();
704
705 let folder_paths = {
706 let project = thread_ref.project().read(cx);
707 let paths: Vec<Arc<Path>> = project
708 .visible_worktrees(cx)
709 .map(|worktree| worktree.read(cx).abs_path())
710 .collect();
711 PathList::new(&paths)
712 };
713
714 let main_worktree_paths = {
715 let project = thread_ref.project().read(cx);
716 let mut main_paths: Vec<Arc<Path>> = Vec::new();
717 for repo in project.repositories(cx).values() {
718 let snapshot = repo.read(cx).snapshot();
719 if snapshot.is_linked_worktree() {
720 main_paths.push(snapshot.original_repo_abs_path.clone());
721 }
722 }
723 main_paths.sort();
724 main_paths.dedup();
725 PathList::new(&main_paths)
726 };
727
728 // Threads without a folder path (e.g. started in an empty
729 // window) are archived by default so they don't get lost,
730 // because they won't show up in the sidebar. Users can reload
731 // them from the archive.
732 let archived = existing_thread
733 .map(|t| t.archived)
734 .unwrap_or(folder_paths.is_empty());
735
736 let metadata = ThreadMetadata {
737 session_id,
738 agent_id,
739 title,
740 created_at: Some(created_at),
741 updated_at,
742 folder_paths,
743 main_worktree_paths,
744 archived,
745 pending_worktree_restore: None,
746 };
747
748 self.save(metadata, cx);
749 }
750 AcpThreadEvent::TokenUsageUpdated
751 | AcpThreadEvent::SubagentSpawned(_)
752 | AcpThreadEvent::PromptCapabilitiesUpdated
753 | AcpThreadEvent::AvailableCommandsUpdated(_)
754 | AcpThreadEvent::ModeUpdated(_)
755 | AcpThreadEvent::ConfigOptionsUpdated(_) => {}
756 }
757 }
758}
759
760impl Global for ThreadMetadataStore {}
761
762struct ThreadMetadataDb(ThreadSafeConnection);
763
764impl Domain for ThreadMetadataDb {
765 const NAME: &str = stringify!(ThreadMetadataDb);
766
767 const MIGRATIONS: &[&str] = &[
768 sql!(
769 CREATE TABLE IF NOT EXISTS sidebar_threads(
770 session_id TEXT PRIMARY KEY,
771 agent_id TEXT,
772 title TEXT NOT NULL,
773 updated_at TEXT NOT NULL,
774 created_at TEXT,
775 folder_paths TEXT,
776 folder_paths_order TEXT
777 ) STRICT;
778 ),
779 sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
780 sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
781 sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
782 sql!(
783 CREATE TABLE IF NOT EXISTS archived_git_worktrees(
784 id INTEGER PRIMARY KEY,
785 worktree_path TEXT NOT NULL,
786 main_repo_path TEXT NOT NULL,
787 branch_name TEXT,
788 commit_hash TEXT NOT NULL,
789 restored INTEGER NOT NULL DEFAULT 0
790 ) STRICT;
791
792 CREATE TABLE IF NOT EXISTS thread_archived_worktrees(
793 session_id TEXT NOT NULL,
794 archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
795 PRIMARY KEY (session_id, archived_worktree_id)
796 ) STRICT;
797 ),
798 sql!(
799 ALTER TABLE archived_git_worktrees ADD COLUMN staged_commit_hash TEXT;
800 ALTER TABLE archived_git_worktrees ADD COLUMN unstaged_commit_hash TEXT;
801 UPDATE archived_git_worktrees SET staged_commit_hash = commit_hash, unstaged_commit_hash = commit_hash WHERE staged_commit_hash IS NULL;
802 ),
803 ];
804}
805
806db::static_connection!(ThreadMetadataDb, []);
807
808impl ThreadMetadataDb {
809 pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
810 self.select::<Arc<str>>(
811 "SELECT session_id FROM sidebar_threads \
812 ORDER BY updated_at DESC",
813 )?()
814 }
815
816 /// List all sidebar thread metadata, ordered by updated_at descending.
817 pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
818 self.select::<ThreadMetadata>(
819 "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order \
820 FROM sidebar_threads \
821 ORDER BY updated_at DESC"
822 )?()
823 }
824
825 /// Upsert metadata for a thread.
826 pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
827 let id = row.session_id.0.clone();
828 let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
829 None
830 } else {
831 Some(row.agent_id.to_string())
832 };
833 let title = row.title.to_string();
834 let updated_at = row.updated_at.to_rfc3339();
835 let created_at = row.created_at.map(|dt| dt.to_rfc3339());
836 let serialized = row.folder_paths.serialize();
837 let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() {
838 (None, None)
839 } else {
840 (Some(serialized.paths), Some(serialized.order))
841 };
842 let main_serialized = row.main_worktree_paths.serialize();
843 let (main_worktree_paths, main_worktree_paths_order) = if row.main_worktree_paths.is_empty()
844 {
845 (None, None)
846 } else {
847 (Some(main_serialized.paths), Some(main_serialized.order))
848 };
849 let archived = row.archived;
850
851 self.write(move |conn| {
852 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) \
853 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \
854 ON CONFLICT(session_id) DO UPDATE SET \
855 agent_id = excluded.agent_id, \
856 title = excluded.title, \
857 updated_at = excluded.updated_at, \
858 created_at = excluded.created_at, \
859 folder_paths = excluded.folder_paths, \
860 folder_paths_order = excluded.folder_paths_order, \
861 archived = excluded.archived, \
862 main_worktree_paths = excluded.main_worktree_paths, \
863 main_worktree_paths_order = excluded.main_worktree_paths_order";
864 let mut stmt = Statement::prepare(conn, sql)?;
865 let mut i = stmt.bind(&id, 1)?;
866 i = stmt.bind(&agent_id, i)?;
867 i = stmt.bind(&title, i)?;
868 i = stmt.bind(&updated_at, i)?;
869 i = stmt.bind(&created_at, i)?;
870 i = stmt.bind(&folder_paths, i)?;
871 i = stmt.bind(&folder_paths_order, i)?;
872 i = stmt.bind(&archived, i)?;
873 i = stmt.bind(&main_worktree_paths, i)?;
874 stmt.bind(&main_worktree_paths_order, i)?;
875 stmt.exec()
876 })
877 .await
878 }
879
880 /// Delete metadata for a single thread.
881 pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> {
882 let id = session_id.0.clone();
883 self.write(move |conn| {
884 let mut stmt =
885 Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?;
886 stmt.bind(&id, 1)?;
887 stmt.exec()
888 })
889 .await
890 }
891
892 pub async fn create_archived_worktree(
893 &self,
894 worktree_path: &str,
895 main_repo_path: &str,
896 branch_name: Option<&str>,
897 staged_commit_hash: &str,
898 unstaged_commit_hash: &str,
899 ) -> anyhow::Result<i64> {
900 let worktree_path = worktree_path.to_string();
901 let main_repo_path = main_repo_path.to_string();
902 let branch_name = branch_name.map(|s| s.to_string());
903 let staged_commit_hash = staged_commit_hash.to_string();
904 let unstaged_commit_hash = unstaged_commit_hash.to_string();
905
906 self.write(move |conn| {
907 let mut stmt = Statement::prepare(
908 conn,
909 "INSERT INTO archived_git_worktrees(worktree_path, main_repo_path, branch_name, commit_hash, staged_commit_hash, unstaged_commit_hash) \
910 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
911 RETURNING id",
912 )?;
913 let mut i = stmt.bind(&worktree_path, 1)?;
914 i = stmt.bind(&main_repo_path, i)?;
915 i = stmt.bind(&branch_name, i)?;
916 i = stmt.bind(&unstaged_commit_hash, i)?;
917 i = stmt.bind(&staged_commit_hash, i)?;
918 stmt.bind(&unstaged_commit_hash, i)?;
919 stmt.maybe_row::<i64>()?.context("expected RETURNING id")
920 })
921 .await
922 }
923
924 pub async fn link_thread_to_archived_worktree(
925 &self,
926 session_id: &str,
927 archived_worktree_id: i64,
928 ) -> anyhow::Result<()> {
929 let session_id = session_id.to_string();
930
931 self.write(move |conn| {
932 let mut stmt = Statement::prepare(
933 conn,
934 "INSERT INTO thread_archived_worktrees(session_id, archived_worktree_id) \
935 VALUES (?1, ?2)",
936 )?;
937 let i = stmt.bind(&session_id, 1)?;
938 stmt.bind(&archived_worktree_id, i)?;
939 stmt.exec()
940 })
941 .await
942 }
943
944 pub async fn get_archived_worktrees_for_thread(
945 &self,
946 session_id: &str,
947 ) -> anyhow::Result<Vec<ArchivedGitWorktree>> {
948 let session_id = session_id.to_string();
949
950 self.select_bound::<String, ArchivedGitWorktree>(
951 "SELECT a.id, a.worktree_path, a.main_repo_path, a.branch_name, a.staged_commit_hash, a.unstaged_commit_hash, a.restored \
952 FROM archived_git_worktrees a \
953 JOIN thread_archived_worktrees t ON a.id = t.archived_worktree_id \
954 WHERE t.session_id = ?1",
955 )?(session_id)
956 }
957
958 pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> {
959 self.write(move |conn| {
960 let mut stmt = Statement::prepare(
961 conn,
962 "DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?",
963 )?;
964 stmt.bind(&id, 1)?;
965 stmt.exec()?;
966
967 let mut stmt =
968 Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?;
969 stmt.bind(&id, 1)?;
970 stmt.exec()
971 })
972 .await
973 }
974
975 pub async fn set_archived_worktree_restored(
976 &self,
977 id: i64,
978 worktree_path: &str,
979 branch_name: Option<&str>,
980 ) -> anyhow::Result<()> {
981 let worktree_path = worktree_path.to_string();
982 let branch_name = branch_name.map(|s| s.to_string());
983
984 self.write(move |conn| {
985 let mut stmt = Statement::prepare(
986 conn,
987 "UPDATE archived_git_worktrees SET restored = 1, worktree_path = ?1, branch_name = ?2 WHERE id = ?3",
988 )?;
989 let mut i = stmt.bind(&worktree_path, 1)?;
990 i = stmt.bind(&branch_name, i)?;
991 stmt.bind(&id, i)?;
992 stmt.exec()
993 })
994 .await
995 }
996}
997
998impl Column for ThreadMetadata {
999 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1000 let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
1001 let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
1002 let (title, next): (String, i32) = Column::column(statement, next)?;
1003 let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
1004 let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1005 let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
1006 let (folder_paths_order_str, next): (Option<String>, i32) =
1007 Column::column(statement, next)?;
1008 let (archived, next): (bool, i32) = Column::column(statement, next)?;
1009 let (main_worktree_paths_str, next): (Option<String>, i32) =
1010 Column::column(statement, next)?;
1011 let (main_worktree_paths_order_str, next): (Option<String>, i32) =
1012 Column::column(statement, next)?;
1013
1014 let agent_id = agent_id
1015 .map(|id| AgentId::new(id))
1016 .unwrap_or(ZED_AGENT_ID.clone());
1017
1018 let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
1019 let created_at = created_at_str
1020 .as_deref()
1021 .map(DateTime::parse_from_rfc3339)
1022 .transpose()?
1023 .map(|dt| dt.with_timezone(&Utc));
1024
1025 let folder_paths = folder_paths_str
1026 .map(|paths| {
1027 PathList::deserialize(&util::path_list::SerializedPathList {
1028 paths,
1029 order: folder_paths_order_str.unwrap_or_default(),
1030 })
1031 })
1032 .unwrap_or_default();
1033
1034 let main_worktree_paths = main_worktree_paths_str
1035 .map(|paths| {
1036 PathList::deserialize(&util::path_list::SerializedPathList {
1037 paths,
1038 order: main_worktree_paths_order_str.unwrap_or_default(),
1039 })
1040 })
1041 .unwrap_or_default();
1042
1043 Ok((
1044 ThreadMetadata {
1045 session_id: acp::SessionId::new(id),
1046 agent_id,
1047 title: title.into(),
1048 updated_at,
1049 created_at,
1050 folder_paths,
1051 main_worktree_paths,
1052 archived,
1053 pending_worktree_restore: None,
1054 },
1055 next,
1056 ))
1057 }
1058}
1059
1060impl Column for ArchivedGitWorktree {
1061 fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
1062 let (id, next): (i64, i32) = Column::column(statement, start_index)?;
1063 let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?;
1064 let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?;
1065 let (branch_name, next): (Option<String>, i32) = Column::column(statement, next)?;
1066 let (staged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1067 let (unstaged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
1068 let (restored_int, next): (i64, i32) = Column::column(statement, next)?;
1069
1070 Ok((
1071 ArchivedGitWorktree {
1072 id,
1073 worktree_path: PathBuf::from(worktree_path_str),
1074 main_repo_path: PathBuf::from(main_repo_path_str),
1075 branch_name,
1076 staged_commit_hash,
1077 unstaged_commit_hash,
1078 restored: restored_int != 0,
1079 },
1080 next,
1081 ))
1082 }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088 use acp_thread::{AgentConnection, StubAgentConnection};
1089 use action_log::ActionLog;
1090 use agent::DbThread;
1091 use agent_client_protocol as acp;
1092 use feature_flags::FeatureFlagAppExt;
1093 use gpui::TestAppContext;
1094 use project::FakeFs;
1095 use project::Project;
1096 use std::path::Path;
1097 use std::rc::Rc;
1098
1099 fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
1100 DbThread {
1101 title: title.to_string().into(),
1102 messages: Vec::new(),
1103 updated_at,
1104 detailed_summary: None,
1105 initial_project_snapshot: None,
1106 cumulative_token_usage: Default::default(),
1107 request_token_usage: Default::default(),
1108 model: None,
1109 profile: None,
1110 imported: false,
1111 subagent_context: None,
1112 speed: None,
1113 thinking_enabled: false,
1114 thinking_effort: None,
1115 draft_prompt: None,
1116 ui_scroll_position: None,
1117 }
1118 }
1119
1120 fn make_metadata(
1121 session_id: &str,
1122 title: &str,
1123 updated_at: DateTime<Utc>,
1124 folder_paths: PathList,
1125 ) -> ThreadMetadata {
1126 ThreadMetadata {
1127 archived: false,
1128 session_id: acp::SessionId::new(session_id),
1129 agent_id: agent::ZED_AGENT_ID.clone(),
1130 title: title.to_string().into(),
1131 updated_at,
1132 created_at: Some(updated_at),
1133 folder_paths,
1134 main_worktree_paths: PathList::default(),
1135 pending_worktree_restore: None,
1136 }
1137 }
1138
1139 fn init_test(cx: &mut TestAppContext) {
1140 cx.update(|cx| {
1141 let settings_store = settings::SettingsStore::test(cx);
1142 cx.set_global(settings_store);
1143 cx.update_flags(true, vec!["agent-v2".to_string()]);
1144 ThreadMetadataStore::init_global(cx);
1145 ThreadStore::init_global(cx);
1146 });
1147 cx.run_until_parked();
1148 }
1149
1150 #[gpui::test]
1151 async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
1152 let first_paths = PathList::new(&[Path::new("/project-a")]);
1153 let second_paths = PathList::new(&[Path::new("/project-b")]);
1154 let now = Utc::now();
1155 let older = now - chrono::Duration::seconds(1);
1156
1157 let thread = std::thread::current();
1158 let test_name = thread.name().unwrap_or("unknown_test");
1159 let db_name = format!("THREAD_METADATA_DB_{}", test_name);
1160 let db = ThreadMetadataDb(smol::block_on(db::open_test_db::<ThreadMetadataDb>(
1161 &db_name,
1162 )));
1163
1164 db.save(make_metadata(
1165 "session-1",
1166 "First Thread",
1167 now,
1168 first_paths.clone(),
1169 ))
1170 .await
1171 .unwrap();
1172 db.save(make_metadata(
1173 "session-2",
1174 "Second Thread",
1175 older,
1176 second_paths.clone(),
1177 ))
1178 .await
1179 .unwrap();
1180
1181 cx.update(|cx| {
1182 let settings_store = settings::SettingsStore::test(cx);
1183 cx.set_global(settings_store);
1184 cx.update_flags(true, vec!["agent-v2".to_string()]);
1185 ThreadMetadataStore::init_global(cx);
1186 });
1187
1188 cx.run_until_parked();
1189
1190 cx.update(|cx| {
1191 let store = ThreadMetadataStore::global(cx);
1192 let store = store.read(cx);
1193
1194 let entry_ids = store
1195 .entry_ids()
1196 .map(|session_id| session_id.0.to_string())
1197 .collect::<Vec<_>>();
1198 assert_eq!(entry_ids.len(), 2);
1199 assert!(entry_ids.contains(&"session-1".to_string()));
1200 assert!(entry_ids.contains(&"session-2".to_string()));
1201
1202 let first_path_entries = store
1203 .entries_for_path(&first_paths)
1204 .map(|entry| entry.session_id.0.to_string())
1205 .collect::<Vec<_>>();
1206 assert_eq!(first_path_entries, vec!["session-1"]);
1207
1208 let second_path_entries = store
1209 .entries_for_path(&second_paths)
1210 .map(|entry| entry.session_id.0.to_string())
1211 .collect::<Vec<_>>();
1212 assert_eq!(second_path_entries, vec!["session-2"]);
1213 });
1214 }
1215
1216 #[gpui::test]
1217 async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
1218 init_test(cx);
1219
1220 let first_paths = PathList::new(&[Path::new("/project-a")]);
1221 let second_paths = PathList::new(&[Path::new("/project-b")]);
1222 let initial_time = Utc::now();
1223 let updated_time = initial_time + chrono::Duration::seconds(1);
1224
1225 let initial_metadata = make_metadata(
1226 "session-1",
1227 "First Thread",
1228 initial_time,
1229 first_paths.clone(),
1230 );
1231
1232 let second_metadata = make_metadata(
1233 "session-2",
1234 "Second Thread",
1235 initial_time,
1236 second_paths.clone(),
1237 );
1238
1239 cx.update(|cx| {
1240 let store = ThreadMetadataStore::global(cx);
1241 store.update(cx, |store, cx| {
1242 store.save(initial_metadata, cx);
1243 store.save(second_metadata, cx);
1244 });
1245 });
1246
1247 cx.run_until_parked();
1248
1249 cx.update(|cx| {
1250 let store = ThreadMetadataStore::global(cx);
1251 let store = store.read(cx);
1252
1253 let first_path_entries = store
1254 .entries_for_path(&first_paths)
1255 .map(|entry| entry.session_id.0.to_string())
1256 .collect::<Vec<_>>();
1257 assert_eq!(first_path_entries, vec!["session-1"]);
1258
1259 let second_path_entries = store
1260 .entries_for_path(&second_paths)
1261 .map(|entry| entry.session_id.0.to_string())
1262 .collect::<Vec<_>>();
1263 assert_eq!(second_path_entries, vec!["session-2"]);
1264 });
1265
1266 let moved_metadata = make_metadata(
1267 "session-1",
1268 "First Thread",
1269 updated_time,
1270 second_paths.clone(),
1271 );
1272
1273 cx.update(|cx| {
1274 let store = ThreadMetadataStore::global(cx);
1275 store.update(cx, |store, cx| {
1276 store.save(moved_metadata, cx);
1277 });
1278 });
1279
1280 cx.run_until_parked();
1281
1282 cx.update(|cx| {
1283 let store = ThreadMetadataStore::global(cx);
1284 let store = store.read(cx);
1285
1286 let entry_ids = store
1287 .entry_ids()
1288 .map(|session_id| session_id.0.to_string())
1289 .collect::<Vec<_>>();
1290 assert_eq!(entry_ids.len(), 2);
1291 assert!(entry_ids.contains(&"session-1".to_string()));
1292 assert!(entry_ids.contains(&"session-2".to_string()));
1293
1294 let first_path_entries = store
1295 .entries_for_path(&first_paths)
1296 .map(|entry| entry.session_id.0.to_string())
1297 .collect::<Vec<_>>();
1298 assert!(first_path_entries.is_empty());
1299
1300 let second_path_entries = store
1301 .entries_for_path(&second_paths)
1302 .map(|entry| entry.session_id.0.to_string())
1303 .collect::<Vec<_>>();
1304 assert_eq!(second_path_entries.len(), 2);
1305 assert!(second_path_entries.contains(&"session-1".to_string()));
1306 assert!(second_path_entries.contains(&"session-2".to_string()));
1307 });
1308
1309 cx.update(|cx| {
1310 let store = ThreadMetadataStore::global(cx);
1311 store.update(cx, |store, cx| {
1312 store.delete(acp::SessionId::new("session-2"), cx);
1313 });
1314 });
1315
1316 cx.run_until_parked();
1317
1318 cx.update(|cx| {
1319 let store = ThreadMetadataStore::global(cx);
1320 let store = store.read(cx);
1321
1322 let entry_ids = store
1323 .entry_ids()
1324 .map(|session_id| session_id.0.to_string())
1325 .collect::<Vec<_>>();
1326 assert_eq!(entry_ids, vec!["session-1"]);
1327
1328 let second_path_entries = store
1329 .entries_for_path(&second_paths)
1330 .map(|entry| entry.session_id.0.to_string())
1331 .collect::<Vec<_>>();
1332 assert_eq!(second_path_entries, vec!["session-1"]);
1333 });
1334 }
1335
1336 #[gpui::test]
1337 async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
1338 init_test(cx);
1339
1340 let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1341 let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1342 let now = Utc::now();
1343
1344 let existing_metadata = ThreadMetadata {
1345 session_id: acp::SessionId::new("a-session-0"),
1346 agent_id: agent::ZED_AGENT_ID.clone(),
1347 title: "Existing Metadata".into(),
1348 updated_at: now - chrono::Duration::seconds(10),
1349 created_at: Some(now - chrono::Duration::seconds(10)),
1350 folder_paths: project_a_paths.clone(),
1351 main_worktree_paths: PathList::default(),
1352 archived: false,
1353 pending_worktree_restore: None,
1354 };
1355
1356 cx.update(|cx| {
1357 let store = ThreadMetadataStore::global(cx);
1358 store.update(cx, |store, cx| {
1359 store.save(existing_metadata, cx);
1360 });
1361 });
1362 cx.run_until_parked();
1363
1364 let threads_to_save = vec![
1365 (
1366 "a-session-0",
1367 "Thread A0 From Native Store",
1368 project_a_paths.clone(),
1369 now,
1370 ),
1371 (
1372 "a-session-1",
1373 "Thread A1",
1374 project_a_paths.clone(),
1375 now + chrono::Duration::seconds(1),
1376 ),
1377 (
1378 "b-session-0",
1379 "Thread B0",
1380 project_b_paths.clone(),
1381 now + chrono::Duration::seconds(2),
1382 ),
1383 (
1384 "projectless",
1385 "Projectless",
1386 PathList::default(),
1387 now + chrono::Duration::seconds(3),
1388 ),
1389 ];
1390
1391 for (session_id, title, paths, updated_at) in &threads_to_save {
1392 let save_task = cx.update(|cx| {
1393 let thread_store = ThreadStore::global(cx);
1394 let session_id = session_id.to_string();
1395 let title = title.to_string();
1396 let paths = paths.clone();
1397 thread_store.update(cx, |store, cx| {
1398 store.save_thread(
1399 acp::SessionId::new(session_id),
1400 make_db_thread(&title, *updated_at),
1401 paths,
1402 cx,
1403 )
1404 })
1405 });
1406 save_task.await.unwrap();
1407 cx.run_until_parked();
1408 }
1409
1410 cx.update(|cx| migrate_thread_metadata(cx));
1411 cx.run_until_parked();
1412
1413 let list = cx.update(|cx| {
1414 let store = ThreadMetadataStore::global(cx);
1415 store.read(cx).entries().cloned().collect::<Vec<_>>()
1416 });
1417
1418 assert_eq!(list.len(), 4);
1419 assert!(
1420 list.iter()
1421 .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
1422 );
1423
1424 let existing_metadata = list
1425 .iter()
1426 .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0")
1427 .unwrap();
1428 assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata");
1429 assert!(!existing_metadata.archived);
1430
1431 let migrated_session_ids = list
1432 .iter()
1433 .map(|metadata| metadata.session_id.0.as_ref())
1434 .collect::<Vec<_>>();
1435 assert!(migrated_session_ids.contains(&"a-session-1"));
1436 assert!(migrated_session_ids.contains(&"b-session-0"));
1437 assert!(migrated_session_ids.contains(&"projectless"));
1438
1439 let migrated_entries = list
1440 .iter()
1441 .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
1442 .collect::<Vec<_>>();
1443 assert!(migrated_entries.iter().all(|metadata| metadata.archived));
1444 }
1445
1446 #[gpui::test]
1447 async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
1448 cx: &mut TestAppContext,
1449 ) {
1450 init_test(cx);
1451
1452 let project_paths = PathList::new(&[Path::new("/project-a")]);
1453 let existing_updated_at = Utc::now();
1454
1455 let existing_metadata = ThreadMetadata {
1456 session_id: acp::SessionId::new("existing-session"),
1457 agent_id: agent::ZED_AGENT_ID.clone(),
1458 title: "Existing Metadata".into(),
1459 updated_at: existing_updated_at,
1460 created_at: Some(existing_updated_at),
1461 folder_paths: project_paths.clone(),
1462 main_worktree_paths: PathList::default(),
1463 archived: false,
1464 pending_worktree_restore: None,
1465 };
1466
1467 cx.update(|cx| {
1468 let store = ThreadMetadataStore::global(cx);
1469 store.update(cx, |store, cx| {
1470 store.save(existing_metadata, cx);
1471 });
1472 });
1473 cx.run_until_parked();
1474
1475 let save_task = cx.update(|cx| {
1476 let thread_store = ThreadStore::global(cx);
1477 thread_store.update(cx, |store, cx| {
1478 store.save_thread(
1479 acp::SessionId::new("existing-session"),
1480 make_db_thread(
1481 "Updated Native Thread Title",
1482 existing_updated_at + chrono::Duration::seconds(1),
1483 ),
1484 project_paths.clone(),
1485 cx,
1486 )
1487 })
1488 });
1489 save_task.await.unwrap();
1490 cx.run_until_parked();
1491
1492 cx.update(|cx| migrate_thread_metadata(cx));
1493 cx.run_until_parked();
1494
1495 let list = cx.update(|cx| {
1496 let store = ThreadMetadataStore::global(cx);
1497 store.read(cx).entries().cloned().collect::<Vec<_>>()
1498 });
1499
1500 assert_eq!(list.len(), 1);
1501 assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
1502 }
1503
1504 #[gpui::test]
1505 async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
1506 cx: &mut TestAppContext,
1507 ) {
1508 init_test(cx);
1509
1510 let project_a_paths = PathList::new(&[Path::new("/project-a")]);
1511 let project_b_paths = PathList::new(&[Path::new("/project-b")]);
1512 let now = Utc::now();
1513
1514 // Create 7 threads for project A and 3 for project B
1515 let mut threads_to_save = Vec::new();
1516 for i in 0..7 {
1517 threads_to_save.push((
1518 format!("a-session-{i}"),
1519 format!("Thread A{i}"),
1520 project_a_paths.clone(),
1521 now + chrono::Duration::seconds(i as i64),
1522 ));
1523 }
1524 for i in 0..3 {
1525 threads_to_save.push((
1526 format!("b-session-{i}"),
1527 format!("Thread B{i}"),
1528 project_b_paths.clone(),
1529 now + chrono::Duration::seconds(i as i64),
1530 ));
1531 }
1532
1533 for (session_id, title, paths, updated_at) in &threads_to_save {
1534 let save_task = cx.update(|cx| {
1535 let thread_store = ThreadStore::global(cx);
1536 let session_id = session_id.to_string();
1537 let title = title.to_string();
1538 let paths = paths.clone();
1539 thread_store.update(cx, |store, cx| {
1540 store.save_thread(
1541 acp::SessionId::new(session_id),
1542 make_db_thread(&title, *updated_at),
1543 paths,
1544 cx,
1545 )
1546 })
1547 });
1548 save_task.await.unwrap();
1549 cx.run_until_parked();
1550 }
1551
1552 cx.update(|cx| migrate_thread_metadata(cx));
1553 cx.run_until_parked();
1554
1555 let list = cx.update(|cx| {
1556 let store = ThreadMetadataStore::global(cx);
1557 store.read(cx).entries().cloned().collect::<Vec<_>>()
1558 });
1559
1560 assert_eq!(list.len(), 10);
1561
1562 // Project A: 5 most recent should be unarchived, 2 oldest should be archived
1563 let mut project_a_entries: Vec<_> = list
1564 .iter()
1565 .filter(|m| m.folder_paths == project_a_paths)
1566 .collect();
1567 assert_eq!(project_a_entries.len(), 7);
1568 project_a_entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1569
1570 for entry in &project_a_entries[..5] {
1571 assert!(
1572 !entry.archived,
1573 "Expected {} to be unarchived (top 5 most recent)",
1574 entry.session_id.0
1575 );
1576 }
1577 for entry in &project_a_entries[5..] {
1578 assert!(
1579 entry.archived,
1580 "Expected {} to be archived (older than top 5)",
1581 entry.session_id.0
1582 );
1583 }
1584
1585 // Project B: all 3 should be unarchived (under the limit)
1586 let project_b_entries: Vec<_> = list
1587 .iter()
1588 .filter(|m| m.folder_paths == project_b_paths)
1589 .collect();
1590 assert_eq!(project_b_entries.len(), 3);
1591 assert!(project_b_entries.iter().all(|m| !m.archived));
1592 }
1593
1594 #[gpui::test]
1595 async fn test_empty_thread_events_do_not_create_metadata(cx: &mut TestAppContext) {
1596 init_test(cx);
1597
1598 let fs = FakeFs::new(cx.executor());
1599 let project = Project::test(fs, None::<&Path>, cx).await;
1600 let connection = Rc::new(StubAgentConnection::new());
1601
1602 let thread = cx
1603 .update(|cx| {
1604 connection
1605 .clone()
1606 .new_session(project.clone(), PathList::default(), cx)
1607 })
1608 .await
1609 .unwrap();
1610 let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1611
1612 cx.update(|cx| {
1613 thread.update(cx, |thread, cx| {
1614 thread.set_title("Draft Thread".into(), cx).detach();
1615 });
1616 });
1617 cx.run_until_parked();
1618
1619 let metadata_ids = cx.update(|cx| {
1620 ThreadMetadataStore::global(cx)
1621 .read(cx)
1622 .entry_ids()
1623 .collect::<Vec<_>>()
1624 });
1625 assert!(
1626 metadata_ids.is_empty(),
1627 "expected empty draft thread title updates to be ignored"
1628 );
1629
1630 cx.update(|cx| {
1631 thread.update(cx, |thread, cx| {
1632 thread.push_user_content_block(None, "Hello".into(), cx);
1633 });
1634 });
1635 cx.run_until_parked();
1636
1637 let metadata_ids = cx.update(|cx| {
1638 ThreadMetadataStore::global(cx)
1639 .read(cx)
1640 .entry_ids()
1641 .collect::<Vec<_>>()
1642 });
1643 assert_eq!(metadata_ids, vec![session_id]);
1644 }
1645
1646 #[gpui::test]
1647 async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
1648 init_test(cx);
1649
1650 let fs = FakeFs::new(cx.executor());
1651 let project = Project::test(fs, None::<&Path>, cx).await;
1652 let connection = Rc::new(StubAgentConnection::new());
1653
1654 let thread = cx
1655 .update(|cx| {
1656 connection
1657 .clone()
1658 .new_session(project.clone(), PathList::default(), cx)
1659 })
1660 .await
1661 .unwrap();
1662 let session_id = cx.read(|cx| thread.read(cx).session_id().clone());
1663
1664 cx.update(|cx| {
1665 thread.update(cx, |thread, cx| {
1666 thread.push_user_content_block(None, "Hello".into(), cx);
1667 });
1668 });
1669 cx.run_until_parked();
1670
1671 let metadata_ids = cx.update(|cx| {
1672 ThreadMetadataStore::global(cx)
1673 .read(cx)
1674 .entry_ids()
1675 .collect::<Vec<_>>()
1676 });
1677 assert_eq!(metadata_ids, vec![session_id.clone()]);
1678
1679 drop(thread);
1680 cx.update(|_| {});
1681 cx.run_until_parked();
1682
1683 let metadata_ids = cx.update(|cx| {
1684 ThreadMetadataStore::global(cx)
1685 .read(cx)
1686 .entry_ids()
1687 .collect::<Vec<_>>()
1688 });
1689 assert_eq!(metadata_ids, vec![session_id]);
1690 }
1691
1692 #[gpui::test]
1693 async fn test_threads_without_project_association_are_archived_by_default(
1694 cx: &mut TestAppContext,
1695 ) {
1696 init_test(cx);
1697
1698 let fs = FakeFs::new(cx.executor());
1699 let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
1700 let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
1701 let connection = Rc::new(StubAgentConnection::new());
1702
1703 let thread_without_worktree = cx
1704 .update(|cx| {
1705 connection.clone().new_session(
1706 project_without_worktree.clone(),
1707 PathList::default(),
1708 cx,
1709 )
1710 })
1711 .await
1712 .unwrap();
1713 let session_without_worktree =
1714 cx.read(|cx| thread_without_worktree.read(cx).session_id().clone());
1715
1716 cx.update(|cx| {
1717 thread_without_worktree.update(cx, |thread, cx| {
1718 thread.push_user_content_block(None, "content".into(), cx);
1719 thread.set_title("No Project Thread".into(), cx).detach();
1720 });
1721 });
1722 cx.run_until_parked();
1723
1724 let thread_with_worktree = cx
1725 .update(|cx| {
1726 connection.clone().new_session(
1727 project_with_worktree.clone(),
1728 PathList::default(),
1729 cx,
1730 )
1731 })
1732 .await
1733 .unwrap();
1734 let session_with_worktree =
1735 cx.read(|cx| thread_with_worktree.read(cx).session_id().clone());
1736
1737 cx.update(|cx| {
1738 thread_with_worktree.update(cx, |thread, cx| {
1739 thread.push_user_content_block(None, "content".into(), cx);
1740 thread.set_title("Project Thread".into(), cx).detach();
1741 });
1742 });
1743 cx.run_until_parked();
1744
1745 cx.update(|cx| {
1746 let store = ThreadMetadataStore::global(cx);
1747 let store = store.read(cx);
1748
1749 let without_worktree = store
1750 .entry(&session_without_worktree)
1751 .expect("missing metadata for thread without project association");
1752 assert!(without_worktree.folder_paths.is_empty());
1753 assert!(
1754 without_worktree.archived,
1755 "expected thread without project association to be archived"
1756 );
1757
1758 let with_worktree = store
1759 .entry(&session_with_worktree)
1760 .expect("missing metadata for thread with project association");
1761 assert_eq!(
1762 with_worktree.folder_paths,
1763 PathList::new(&[Path::new("/project-a")])
1764 );
1765 assert!(
1766 !with_worktree.archived,
1767 "expected thread with project association to remain unarchived"
1768 );
1769 });
1770 }
1771
1772 #[gpui::test]
1773 async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
1774 init_test(cx);
1775
1776 let fs = FakeFs::new(cx.executor());
1777 let project = Project::test(fs, None::<&Path>, cx).await;
1778 let connection = Rc::new(StubAgentConnection::new());
1779
1780 // Create a regular (non-subagent) AcpThread.
1781 let regular_thread = cx
1782 .update(|cx| {
1783 connection
1784 .clone()
1785 .new_session(project.clone(), PathList::default(), cx)
1786 })
1787 .await
1788 .unwrap();
1789
1790 let regular_session_id = cx.read(|cx| regular_thread.read(cx).session_id().clone());
1791
1792 // Set a title on the regular thread to trigger a save via handle_thread_update.
1793 cx.update(|cx| {
1794 regular_thread.update(cx, |thread, cx| {
1795 thread.push_user_content_block(None, "content".into(), cx);
1796 thread.set_title("Regular Thread".into(), cx).detach();
1797 });
1798 });
1799 cx.run_until_parked();
1800
1801 // Create a subagent AcpThread
1802 let subagent_session_id = acp::SessionId::new("subagent-session");
1803 let subagent_thread = cx.update(|cx| {
1804 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1805 cx.new(|cx| {
1806 acp_thread::AcpThread::new(
1807 Some(regular_session_id.clone()),
1808 Some("Subagent Thread".into()),
1809 None,
1810 connection.clone(),
1811 project.clone(),
1812 action_log,
1813 subagent_session_id.clone(),
1814 watch::Receiver::constant(acp::PromptCapabilities::new()),
1815 cx,
1816 )
1817 })
1818 });
1819
1820 // Set a title on the subagent thread to trigger handle_thread_update.
1821 cx.update(|cx| {
1822 subagent_thread.update(cx, |thread, cx| {
1823 thread
1824 .set_title("Subagent Thread Title".into(), cx)
1825 .detach();
1826 });
1827 });
1828 cx.run_until_parked();
1829
1830 // List all metadata from the store cache.
1831 let list = cx.update(|cx| {
1832 let store = ThreadMetadataStore::global(cx);
1833 store.read(cx).entries().cloned().collect::<Vec<_>>()
1834 });
1835
1836 // The subagent thread should NOT appear in the sidebar metadata.
1837 // Only the regular thread should be listed.
1838 assert_eq!(
1839 list.len(),
1840 1,
1841 "Expected only the regular thread in sidebar metadata, \
1842 but found {} entries (subagent threads are leaking into the sidebar)",
1843 list.len(),
1844 );
1845 assert_eq!(list[0].session_id, regular_session_id);
1846 assert_eq!(list[0].title.as_ref(), "Regular Thread");
1847 }
1848
1849 #[test]
1850 fn test_dedup_db_operations_keeps_latest_operation_for_session() {
1851 let now = Utc::now();
1852
1853 let operations = vec![
1854 DbOperation::Upsert(make_metadata(
1855 "session-1",
1856 "First Thread",
1857 now,
1858 PathList::default(),
1859 )),
1860 DbOperation::Delete(acp::SessionId::new("session-1")),
1861 ];
1862
1863 let deduped = ThreadMetadataStore::dedup_db_operations(operations);
1864
1865 assert_eq!(deduped.len(), 1);
1866 assert_eq!(
1867 deduped[0],
1868 DbOperation::Delete(acp::SessionId::new("session-1"))
1869 );
1870 }
1871
1872 #[test]
1873 fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
1874 let now = Utc::now();
1875 let later = now + chrono::Duration::seconds(1);
1876
1877 let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
1878 let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
1879
1880 let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1881 DbOperation::Upsert(old_metadata),
1882 DbOperation::Upsert(new_metadata.clone()),
1883 ]);
1884
1885 assert_eq!(deduped.len(), 1);
1886 assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
1887 }
1888
1889 #[test]
1890 fn test_dedup_db_operations_preserves_distinct_sessions() {
1891 let now = Utc::now();
1892
1893 let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
1894 let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
1895 let deduped = ThreadMetadataStore::dedup_db_operations(vec![
1896 DbOperation::Upsert(metadata1.clone()),
1897 DbOperation::Upsert(metadata2.clone()),
1898 ]);
1899
1900 assert_eq!(deduped.len(), 2);
1901 assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
1902 assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
1903 }
1904
1905 #[gpui::test]
1906 async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
1907 init_test(cx);
1908
1909 let paths = PathList::new(&[Path::new("/project-a")]);
1910 let now = Utc::now();
1911 let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
1912
1913 cx.update(|cx| {
1914 let store = ThreadMetadataStore::global(cx);
1915 store.update(cx, |store, cx| {
1916 store.save(metadata, cx);
1917 });
1918 });
1919
1920 cx.run_until_parked();
1921
1922 cx.update(|cx| {
1923 let store = ThreadMetadataStore::global(cx);
1924 let store = store.read(cx);
1925
1926 let path_entries = store
1927 .entries_for_path(&paths)
1928 .map(|e| e.session_id.0.to_string())
1929 .collect::<Vec<_>>();
1930 assert_eq!(path_entries, vec!["session-1"]);
1931
1932 let archived = store
1933 .archived_entries()
1934 .map(|e| e.session_id.0.to_string())
1935 .collect::<Vec<_>>();
1936 assert!(archived.is_empty());
1937 });
1938
1939 cx.update(|cx| {
1940 let store = ThreadMetadataStore::global(cx);
1941 store.update(cx, |store, cx| {
1942 store.archive(&acp::SessionId::new("session-1"), cx);
1943 });
1944 });
1945
1946 cx.run_until_parked();
1947
1948 cx.update(|cx| {
1949 let store = ThreadMetadataStore::global(cx);
1950 let store = store.read(cx);
1951
1952 let path_entries = store
1953 .entries_for_path(&paths)
1954 .map(|e| e.session_id.0.to_string())
1955 .collect::<Vec<_>>();
1956 assert!(path_entries.is_empty());
1957
1958 let archived = store.archived_entries().collect::<Vec<_>>();
1959 assert_eq!(archived.len(), 1);
1960 assert_eq!(archived[0].session_id.0.as_ref(), "session-1");
1961 assert!(archived[0].archived);
1962 });
1963
1964 cx.update(|cx| {
1965 let store = ThreadMetadataStore::global(cx);
1966 store.update(cx, |store, cx| {
1967 store.unarchive(&acp::SessionId::new("session-1"), cx);
1968 });
1969 });
1970
1971 cx.run_until_parked();
1972
1973 cx.update(|cx| {
1974 let store = ThreadMetadataStore::global(cx);
1975 let store = store.read(cx);
1976
1977 let path_entries = store
1978 .entries_for_path(&paths)
1979 .map(|e| e.session_id.0.to_string())
1980 .collect::<Vec<_>>();
1981 assert_eq!(path_entries, vec!["session-1"]);
1982
1983 let archived = store
1984 .archived_entries()
1985 .map(|e| e.session_id.0.to_string())
1986 .collect::<Vec<_>>();
1987 assert!(archived.is_empty());
1988 });
1989 }
1990
1991 #[gpui::test]
1992 async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
1993 init_test(cx);
1994
1995 let paths = PathList::new(&[Path::new("/project-a")]);
1996 let now = Utc::now();
1997
1998 let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
1999 let metadata2 = make_metadata(
2000 "session-2",
2001 "Archived Thread",
2002 now - chrono::Duration::seconds(1),
2003 paths.clone(),
2004 );
2005
2006 cx.update(|cx| {
2007 let store = ThreadMetadataStore::global(cx);
2008 store.update(cx, |store, cx| {
2009 store.save(metadata1, cx);
2010 store.save(metadata2, cx);
2011 });
2012 });
2013
2014 cx.run_until_parked();
2015
2016 cx.update(|cx| {
2017 let store = ThreadMetadataStore::global(cx);
2018 store.update(cx, |store, cx| {
2019 store.archive(&acp::SessionId::new("session-2"), cx);
2020 });
2021 });
2022
2023 cx.run_until_parked();
2024
2025 cx.update(|cx| {
2026 let store = ThreadMetadataStore::global(cx);
2027 let store = store.read(cx);
2028
2029 let path_entries = store
2030 .entries_for_path(&paths)
2031 .map(|e| e.session_id.0.to_string())
2032 .collect::<Vec<_>>();
2033 assert_eq!(path_entries, vec!["session-1"]);
2034
2035 let all_entries = store
2036 .entries()
2037 .map(|e| e.session_id.0.to_string())
2038 .collect::<Vec<_>>();
2039 assert_eq!(all_entries.len(), 2);
2040 assert!(all_entries.contains(&"session-1".to_string()));
2041 assert!(all_entries.contains(&"session-2".to_string()));
2042
2043 let archived = store
2044 .archived_entries()
2045 .map(|e| e.session_id.0.to_string())
2046 .collect::<Vec<_>>();
2047 assert_eq!(archived, vec!["session-2"]);
2048 });
2049 }
2050
2051 #[gpui::test]
2052 async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
2053 init_test(cx);
2054
2055 let paths = PathList::new(&[Path::new("/project-a")]);
2056 let now = Utc::now();
2057
2058 let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
2059 let m2 = make_metadata(
2060 "session-2",
2061 "Thread Two",
2062 now - chrono::Duration::seconds(1),
2063 paths.clone(),
2064 );
2065 let m3 = make_metadata(
2066 "session-3",
2067 "Thread Three",
2068 now - chrono::Duration::seconds(2),
2069 paths,
2070 );
2071
2072 cx.update(|cx| {
2073 let store = ThreadMetadataStore::global(cx);
2074 store.update(cx, |store, cx| {
2075 store.save_all(vec![m1, m2, m3], cx);
2076 });
2077 });
2078
2079 cx.run_until_parked();
2080
2081 cx.update(|cx| {
2082 let store = ThreadMetadataStore::global(cx);
2083 let store = store.read(cx);
2084
2085 let all_entries = store
2086 .entries()
2087 .map(|e| e.session_id.0.to_string())
2088 .collect::<Vec<_>>();
2089 assert_eq!(all_entries.len(), 3);
2090 assert!(all_entries.contains(&"session-1".to_string()));
2091 assert!(all_entries.contains(&"session-2".to_string()));
2092 assert!(all_entries.contains(&"session-3".to_string()));
2093
2094 let entry_ids = store.entry_ids().collect::<Vec<_>>();
2095 assert_eq!(entry_ids.len(), 3);
2096 });
2097 }
2098
2099 #[gpui::test]
2100 async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
2101 init_test(cx);
2102
2103 let paths = PathList::new(&[Path::new("/project-a")]);
2104 let now = Utc::now();
2105 let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
2106
2107 cx.update(|cx| {
2108 let store = ThreadMetadataStore::global(cx);
2109 store.update(cx, |store, cx| {
2110 store.save(metadata, cx);
2111 });
2112 });
2113
2114 cx.run_until_parked();
2115
2116 cx.update(|cx| {
2117 let store = ThreadMetadataStore::global(cx);
2118 store.update(cx, |store, cx| {
2119 store.archive(&acp::SessionId::new("session-1"), cx);
2120 });
2121 });
2122
2123 cx.run_until_parked();
2124
2125 cx.update(|cx| {
2126 let store = ThreadMetadataStore::global(cx);
2127 store.update(cx, |store, cx| {
2128 let _ = store.reload(cx);
2129 });
2130 });
2131
2132 cx.run_until_parked();
2133
2134 cx.update(|cx| {
2135 let store = ThreadMetadataStore::global(cx);
2136 let store = store.read(cx);
2137
2138 let thread = store
2139 .entries()
2140 .find(|e| e.session_id.0.as_ref() == "session-1")
2141 .expect("thread should exist after reload");
2142 assert!(thread.archived);
2143
2144 let path_entries = store
2145 .entries_for_path(&paths)
2146 .map(|e| e.session_id.0.to_string())
2147 .collect::<Vec<_>>();
2148 assert!(path_entries.is_empty());
2149
2150 let archived = store
2151 .archived_entries()
2152 .map(|e| e.session_id.0.to_string())
2153 .collect::<Vec<_>>();
2154 assert_eq!(archived, vec!["session-1"]);
2155 });
2156 }
2157
2158 #[gpui::test]
2159 async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
2160 init_test(cx);
2161
2162 cx.run_until_parked();
2163
2164 cx.update(|cx| {
2165 let store = ThreadMetadataStore::global(cx);
2166 store.update(cx, |store, cx| {
2167 store.archive(&acp::SessionId::new("nonexistent"), cx);
2168 });
2169 });
2170
2171 cx.run_until_parked();
2172
2173 cx.update(|cx| {
2174 let store = ThreadMetadataStore::global(cx);
2175 let store = store.read(cx);
2176
2177 assert!(store.is_empty());
2178 assert_eq!(store.entries().count(), 0);
2179 assert_eq!(store.archived_entries().count(), 0);
2180 });
2181 }
2182
2183 #[gpui::test]
2184 async fn test_save_followed_by_archiving_without_parking(cx: &mut TestAppContext) {
2185 init_test(cx);
2186
2187 let paths = PathList::new(&[Path::new("/project-a")]);
2188 let now = Utc::now();
2189 let metadata = make_metadata("session-1", "Thread 1", now, paths);
2190 let session_id = metadata.session_id.clone();
2191
2192 cx.update(|cx| {
2193 let store = ThreadMetadataStore::global(cx);
2194 store.update(cx, |store, cx| {
2195 store.save(metadata.clone(), cx);
2196 store.archive(&session_id, cx);
2197 });
2198 });
2199
2200 cx.run_until_parked();
2201
2202 cx.update(|cx| {
2203 let store = ThreadMetadataStore::global(cx);
2204 let store = store.read(cx);
2205
2206 let entries: Vec<ThreadMetadata> = store.entries().cloned().collect();
2207 pretty_assertions::assert_eq!(
2208 entries,
2209 vec![ThreadMetadata {
2210 archived: true,
2211 ..metadata
2212 }]
2213 );
2214 });
2215 }
2216
2217 #[gpui::test]
2218 async fn test_create_and_retrieve_archived_worktree(cx: &mut TestAppContext) {
2219 init_test(cx);
2220 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2221
2222 let id = store
2223 .read_with(cx, |store, cx| {
2224 store.create_archived_worktree(
2225 "/tmp/worktree",
2226 "/home/user/repo",
2227 Some("feature-branch"),
2228 "abc123def456",
2229 "abc123def456",
2230 cx,
2231 )
2232 })
2233 .await
2234 .unwrap();
2235
2236 store
2237 .read_with(cx, |store, cx| {
2238 store.link_thread_to_archived_worktree("session-1", id, cx)
2239 })
2240 .await
2241 .unwrap();
2242
2243 let worktrees = store
2244 .read_with(cx, |store, cx| {
2245 store.get_archived_worktrees_for_thread("session-1", cx)
2246 })
2247 .await
2248 .unwrap();
2249
2250 assert_eq!(worktrees.len(), 1);
2251 let wt = &worktrees[0];
2252 assert_eq!(wt.id, id);
2253 assert_eq!(wt.worktree_path, PathBuf::from("/tmp/worktree"));
2254 assert_eq!(wt.main_repo_path, PathBuf::from("/home/user/repo"));
2255 assert_eq!(wt.branch_name.as_deref(), Some("feature-branch"));
2256 assert_eq!(wt.staged_commit_hash, "abc123def456");
2257 assert_eq!(wt.unstaged_commit_hash, "abc123def456");
2258 assert!(!wt.restored);
2259 }
2260
2261 #[gpui::test]
2262 async fn test_delete_archived_worktree(cx: &mut TestAppContext) {
2263 init_test(cx);
2264 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2265
2266 let id = store
2267 .read_with(cx, |store, cx| {
2268 store.create_archived_worktree(
2269 "/tmp/worktree",
2270 "/home/user/repo",
2271 Some("main"),
2272 "deadbeef",
2273 "deadbeef",
2274 cx,
2275 )
2276 })
2277 .await
2278 .unwrap();
2279
2280 store
2281 .read_with(cx, |store, cx| {
2282 store.link_thread_to_archived_worktree("session-1", id, cx)
2283 })
2284 .await
2285 .unwrap();
2286
2287 store
2288 .read_with(cx, |store, cx| store.delete_archived_worktree(id, cx))
2289 .await
2290 .unwrap();
2291
2292 let worktrees = store
2293 .read_with(cx, |store, cx| {
2294 store.get_archived_worktrees_for_thread("session-1", cx)
2295 })
2296 .await
2297 .unwrap();
2298 assert!(worktrees.is_empty());
2299 }
2300
2301 #[gpui::test]
2302 async fn test_set_archived_worktree_restored(cx: &mut TestAppContext) {
2303 init_test(cx);
2304 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2305
2306 let id = store
2307 .read_with(cx, |store, cx| {
2308 store.create_archived_worktree(
2309 "/tmp/old-worktree",
2310 "/home/user/repo",
2311 Some("old-branch"),
2312 "abc123",
2313 "abc123",
2314 cx,
2315 )
2316 })
2317 .await
2318 .unwrap();
2319
2320 store
2321 .read_with(cx, |store, cx| {
2322 store.set_archived_worktree_restored(
2323 id,
2324 "/tmp/new-worktree",
2325 Some("new-branch"),
2326 cx,
2327 )
2328 })
2329 .await
2330 .unwrap();
2331
2332 store
2333 .read_with(cx, |store, cx| {
2334 store.link_thread_to_archived_worktree("session-1", id, cx)
2335 })
2336 .await
2337 .unwrap();
2338
2339 let worktrees = store
2340 .read_with(cx, |store, cx| {
2341 store.get_archived_worktrees_for_thread("session-1", cx)
2342 })
2343 .await
2344 .unwrap();
2345
2346 assert_eq!(worktrees.len(), 1);
2347 let wt = &worktrees[0];
2348 assert!(wt.restored);
2349 assert_eq!(wt.worktree_path, PathBuf::from("/tmp/new-worktree"));
2350 assert_eq!(wt.branch_name.as_deref(), Some("new-branch"));
2351 }
2352
2353 #[gpui::test]
2354 async fn test_link_multiple_threads_to_archived_worktree(cx: &mut TestAppContext) {
2355 init_test(cx);
2356 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2357
2358 let id = store
2359 .read_with(cx, |store, cx| {
2360 store.create_archived_worktree(
2361 "/tmp/worktree",
2362 "/home/user/repo",
2363 None,
2364 "abc123",
2365 "abc123",
2366 cx,
2367 )
2368 })
2369 .await
2370 .unwrap();
2371
2372 store
2373 .read_with(cx, |store, cx| {
2374 store.link_thread_to_archived_worktree("session-1", id, cx)
2375 })
2376 .await
2377 .unwrap();
2378
2379 store
2380 .read_with(cx, |store, cx| {
2381 store.link_thread_to_archived_worktree("session-2", id, cx)
2382 })
2383 .await
2384 .unwrap();
2385
2386 let wt1 = store
2387 .read_with(cx, |store, cx| {
2388 store.get_archived_worktrees_for_thread("session-1", cx)
2389 })
2390 .await
2391 .unwrap();
2392
2393 let wt2 = store
2394 .read_with(cx, |store, cx| {
2395 store.get_archived_worktrees_for_thread("session-2", cx)
2396 })
2397 .await
2398 .unwrap();
2399
2400 assert_eq!(wt1.len(), 1);
2401 assert_eq!(wt2.len(), 1);
2402 assert_eq!(wt1[0].id, wt2[0].id);
2403 }
2404
2405 #[gpui::test]
2406 async fn test_all_session_ids_for_path(cx: &mut TestAppContext) {
2407 init_test(cx);
2408 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2409 let paths = PathList::new(&[Path::new("/project-x")]);
2410
2411 let meta1 = ThreadMetadata {
2412 session_id: acp::SessionId::new("session-1"),
2413 agent_id: agent::ZED_AGENT_ID.clone(),
2414 title: "Thread 1".into(),
2415 updated_at: Utc::now(),
2416 created_at: Some(Utc::now()),
2417 folder_paths: paths.clone(),
2418 main_worktree_paths: PathList::default(),
2419 archived: false,
2420 pending_worktree_restore: None,
2421 };
2422 let meta2 = ThreadMetadata {
2423 session_id: acp::SessionId::new("session-2"),
2424 agent_id: agent::ZED_AGENT_ID.clone(),
2425 title: "Thread 2".into(),
2426 updated_at: Utc::now(),
2427 created_at: Some(Utc::now()),
2428 folder_paths: paths.clone(),
2429 main_worktree_paths: PathList::default(),
2430 archived: true,
2431 pending_worktree_restore: None,
2432 };
2433
2434 store.update(cx, |store, _cx| {
2435 store.save_internal(meta1);
2436 store.save_internal(meta2);
2437 });
2438
2439 let ids: HashSet<acp::SessionId> = store.read_with(cx, |store, _cx| {
2440 store.all_session_ids_for_path(&paths).cloned().collect()
2441 });
2442
2443 assert!(ids.contains(&acp::SessionId::new("session-1")));
2444 assert!(ids.contains(&acp::SessionId::new("session-2")));
2445 assert_eq!(ids.len(), 2);
2446 }
2447
2448 #[gpui::test]
2449 async fn test_two_sha_round_trip(cx: &mut TestAppContext) {
2450 init_test(cx);
2451 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2452
2453 let id = store
2454 .read_with(cx, |store, cx| {
2455 store.create_archived_worktree(
2456 "/tmp/worktree",
2457 "/home/user/repo",
2458 Some("feature"),
2459 "staged_sha_aaa",
2460 "unstaged_sha_bbb",
2461 cx,
2462 )
2463 })
2464 .await
2465 .unwrap();
2466
2467 store
2468 .read_with(cx, |store, cx| {
2469 store.link_thread_to_archived_worktree("session-1", id, cx)
2470 })
2471 .await
2472 .unwrap();
2473
2474 let worktrees = store
2475 .read_with(cx, |store, cx| {
2476 store.get_archived_worktrees_for_thread("session-1", cx)
2477 })
2478 .await
2479 .unwrap();
2480
2481 assert_eq!(worktrees.len(), 1);
2482 let wt = &worktrees[0];
2483 assert_eq!(wt.staged_commit_hash, "staged_sha_aaa");
2484 assert_eq!(wt.unstaged_commit_hash, "unstaged_sha_bbb");
2485 assert_eq!(wt.branch_name.as_deref(), Some("feature"));
2486 assert!(!wt.restored);
2487 }
2488
2489 #[gpui::test]
2490 async fn test_complete_worktree_restore_single_path(cx: &mut TestAppContext) {
2491 init_test(cx);
2492 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2493
2494 let original_paths = PathList::new(&[Path::new("/projects/worktree-a")]);
2495 let meta = make_metadata("session-1", "Thread 1", Utc::now(), original_paths);
2496
2497 store.update(cx, |store, cx| {
2498 store.save_manually(meta, cx);
2499 });
2500
2501 let replacements = vec![(
2502 PathBuf::from("/projects/worktree-a"),
2503 PathBuf::from("/projects/worktree-a-restored"),
2504 )];
2505
2506 store.update(cx, |store, cx| {
2507 store.complete_worktree_restore(&acp::SessionId::new("session-1"), &replacements, cx);
2508 });
2509
2510 let entry = store.read_with(cx, |store, _cx| {
2511 store.entry(&acp::SessionId::new("session-1")).cloned()
2512 });
2513 let entry = entry.unwrap();
2514 assert!(entry.pending_worktree_restore.is_none());
2515 assert_eq!(
2516 entry.folder_paths.paths(),
2517 &[PathBuf::from("/projects/worktree-a-restored")]
2518 );
2519 }
2520
2521 #[gpui::test]
2522 async fn test_complete_worktree_restore_multiple_paths(cx: &mut TestAppContext) {
2523 init_test(cx);
2524 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2525
2526 let original_paths = PathList::new(&[
2527 Path::new("/projects/worktree-a"),
2528 Path::new("/projects/worktree-b"),
2529 Path::new("/other/unrelated"),
2530 ]);
2531 let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
2532
2533 store.update(cx, |store, cx| {
2534 store.save_manually(meta, cx);
2535 });
2536
2537 let replacements = vec![
2538 (
2539 PathBuf::from("/projects/worktree-a"),
2540 PathBuf::from("/restored/worktree-a"),
2541 ),
2542 (
2543 PathBuf::from("/projects/worktree-b"),
2544 PathBuf::from("/restored/worktree-b"),
2545 ),
2546 ];
2547
2548 store.update(cx, |store, cx| {
2549 store.complete_worktree_restore(
2550 &acp::SessionId::new("session-multi"),
2551 &replacements,
2552 cx,
2553 );
2554 });
2555
2556 let entry = store.read_with(cx, |store, _cx| {
2557 store.entry(&acp::SessionId::new("session-multi")).cloned()
2558 });
2559 let entry = entry.unwrap();
2560 assert!(entry.pending_worktree_restore.is_none());
2561
2562 let paths = entry.folder_paths.paths();
2563 assert_eq!(paths.len(), 3);
2564 assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
2565 assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
2566 assert!(paths.contains(&PathBuf::from("/other/unrelated")));
2567 }
2568
2569 #[gpui::test]
2570 async fn test_complete_worktree_restore_preserves_unmatched_paths(cx: &mut TestAppContext) {
2571 init_test(cx);
2572 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2573
2574 let original_paths =
2575 PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
2576 let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
2577
2578 store.update(cx, |store, cx| {
2579 store.save_manually(meta, cx);
2580 });
2581
2582 let replacements = vec![
2583 (
2584 PathBuf::from("/projects/worktree-a"),
2585 PathBuf::from("/new/worktree-a"),
2586 ),
2587 (
2588 PathBuf::from("/nonexistent/path"),
2589 PathBuf::from("/should/not/appear"),
2590 ),
2591 ];
2592
2593 store.update(cx, |store, cx| {
2594 store.complete_worktree_restore(
2595 &acp::SessionId::new("session-partial"),
2596 &replacements,
2597 cx,
2598 );
2599 });
2600
2601 let entry = store.read_with(cx, |store, _cx| {
2602 store
2603 .entry(&acp::SessionId::new("session-partial"))
2604 .cloned()
2605 });
2606 let entry = entry.unwrap();
2607 let paths = entry.folder_paths.paths();
2608 assert_eq!(paths.len(), 2);
2609 assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
2610 assert!(paths.contains(&PathBuf::from("/other/path")));
2611 assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
2612 }
2613
2614 #[gpui::test]
2615 async fn test_multiple_archived_worktrees_per_thread(cx: &mut TestAppContext) {
2616 init_test(cx);
2617 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
2618
2619 let id1 = store
2620 .read_with(cx, |store, cx| {
2621 store.create_archived_worktree(
2622 "/projects/worktree-a",
2623 "/home/user/repo",
2624 Some("branch-a"),
2625 "staged_a",
2626 "unstaged_a",
2627 cx,
2628 )
2629 })
2630 .await
2631 .unwrap();
2632
2633 let id2 = store
2634 .read_with(cx, |store, cx| {
2635 store.create_archived_worktree(
2636 "/projects/worktree-b",
2637 "/home/user/repo",
2638 Some("branch-b"),
2639 "staged_b",
2640 "unstaged_b",
2641 cx,
2642 )
2643 })
2644 .await
2645 .unwrap();
2646
2647 store
2648 .read_with(cx, |store, cx| {
2649 store.link_thread_to_archived_worktree("session-1", id1, cx)
2650 })
2651 .await
2652 .unwrap();
2653
2654 store
2655 .read_with(cx, |store, cx| {
2656 store.link_thread_to_archived_worktree("session-1", id2, cx)
2657 })
2658 .await
2659 .unwrap();
2660
2661 let worktrees = store
2662 .read_with(cx, |store, cx| {
2663 store.get_archived_worktrees_for_thread("session-1", cx)
2664 })
2665 .await
2666 .unwrap();
2667
2668 assert_eq!(worktrees.len(), 2);
2669
2670 let wt_a = worktrees
2671 .iter()
2672 .find(|w| w.worktree_path.as_path() == Path::new("/projects/worktree-a"))
2673 .unwrap();
2674 assert_eq!(wt_a.staged_commit_hash, "staged_a");
2675 assert_eq!(wt_a.unstaged_commit_hash, "unstaged_a");
2676 assert_eq!(wt_a.branch_name.as_deref(), Some("branch-a"));
2677
2678 let wt_b = worktrees
2679 .iter()
2680 .find(|w| w.worktree_path.as_path() == Path::new("/projects/worktree-b"))
2681 .unwrap();
2682 assert_eq!(wt_b.staged_commit_hash, "staged_b");
2683 assert_eq!(wt_b.unstaged_commit_hash, "unstaged_b");
2684 assert_eq!(wt_b.branch_name.as_deref(), Some("branch-b"));
2685 }
2686}