@@ -1,5 +1,6 @@
use std::{path::Path, sync::Arc};
+use acp_thread::AcpThreadEvent;
use agent::{ThreadStore, ZED_AGENT_ID};
use agent_client_protocol as acp;
use anyhow::Context as _;
@@ -138,49 +139,13 @@ impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
}
}
-impl ThreadMetadata {
- pub fn from_thread(
- is_archived: bool,
- thread: &Entity<acp_thread::AcpThread>,
- cx: &App,
- ) -> Self {
- let thread_ref = thread.read(cx);
- let session_id = thread_ref.session_id().clone();
- let title = thread_ref
- .title()
- .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
- let updated_at = Utc::now();
-
- let agent_id = thread_ref.connection().agent_id();
-
- let folder_paths = {
- let project = thread_ref.project().read(cx);
- let paths: Vec<Arc<Path>> = project
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path())
- .collect();
- PathList::new(&paths)
- };
-
- Self {
- session_id,
- agent_id,
- title,
- created_at: Some(updated_at), // handled by db `ON CONFLICT`
- updated_at,
- folder_paths,
- archived: is_archived,
- }
- }
-}
-
/// The store holds all metadata needed to show threads in the sidebar/the archive.
///
/// Automatically listens to AcpThread events and updates metadata if it has changed.
pub struct ThreadMetadataStore {
db: ThreadMetadataDb,
threads: HashMap<acp::SessionId, ThreadMetadata>,
- threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>>,
+ threads_by_paths: HashMap<PathList, HashSet<acp::SessionId>>,
reload_task: Option<Shared<Task<()>>>,
session_subscriptions: HashMap<acp::SessionId, Subscription>,
pending_thread_ops_tx: smol::channel::Sender<DbOperation>,
@@ -189,14 +154,14 @@ pub struct ThreadMetadataStore {
#[derive(Debug, PartialEq)]
enum DbOperation {
- Insert(ThreadMetadata),
+ Upsert(ThreadMetadata),
Delete(acp::SessionId),
}
impl DbOperation {
fn id(&self) -> &acp::SessionId {
match self {
- DbOperation::Insert(thread) => &thread.session_id,
+ DbOperation::Upsert(thread) => &thread.session_id,
DbOperation::Delete(session_id) => session_id,
}
}
@@ -248,12 +213,12 @@ impl ThreadMetadataStore {
}
/// Returns all threads.
- pub fn entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
- self.threads.values().cloned()
+ pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
+ self.threads.values()
}
/// Returns all archived threads.
- pub fn archived_entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
+ pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
self.entries().filter(|t| t.archived)
}
@@ -261,13 +226,13 @@ impl ThreadMetadataStore {
pub fn entries_for_path(
&self,
path_list: &PathList,
- ) -> impl Iterator<Item = ThreadMetadata> + '_ {
+ ) -> impl Iterator<Item = &ThreadMetadata> + '_ {
self.threads_by_paths
.get(path_list)
.into_iter()
.flatten()
+ .filter_map(|s| self.threads.get(s))
.filter(|s| !s.archived)
- .cloned()
}
fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
@@ -291,7 +256,7 @@ impl ThreadMetadataStore {
this.threads_by_paths
.entry(row.folder_paths.clone())
.or_default()
- .push(row.clone());
+ .insert(row.session_id.clone());
this.threads.insert(row.session_id.clone(), row);
}
@@ -310,19 +275,44 @@ impl ThreadMetadataStore {
}
for metadata in metadata {
- self.pending_thread_ops_tx
- .try_send(DbOperation::Insert(metadata))
- .log_err();
+ self.save_internal(metadata);
}
+ cx.notify();
}
- pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn save_manually(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
+ self.save(metadata, cx)
+ }
+
+ fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
if !cx.has_flag::<AgentV2FeatureFlag>() {
return;
}
+ self.save_internal(metadata);
+ cx.notify();
+ }
+
+ fn save_internal(&mut self, metadata: ThreadMetadata) {
+ // If the folder paths have changed, we need to clear the old entry
+ if let Some(thread) = self.threads.get(&metadata.session_id)
+ && thread.folder_paths != metadata.folder_paths
+ && let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths)
+ {
+ session_ids.remove(&metadata.session_id);
+ }
+
+ self.threads
+ .insert(metadata.session_id.clone(), metadata.clone());
+
+ self.threads_by_paths
+ .entry(metadata.folder_paths.clone())
+ .or_default()
+ .insert(metadata.session_id.clone());
+
self.pending_thread_ops_tx
- .try_send(DbOperation::Insert(metadata))
+ .try_send(DbOperation::Upsert(metadata))
.log_err();
}
@@ -345,13 +335,10 @@ impl ThreadMetadataStore {
}
if let Some(thread) = self.threads.get(session_id) {
- self.save(
- ThreadMetadata {
- archived,
- ..thread.clone()
- },
- cx,
- );
+ self.save_internal(ThreadMetadata {
+ archived,
+ ..thread.clone()
+ });
cx.notify();
}
}
@@ -361,9 +348,16 @@ impl ThreadMetadataStore {
return;
}
+ if let Some(thread) = self.threads.get(&session_id)
+ && let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths)
+ {
+ session_ids.remove(&session_id);
+ }
+ self.threads.remove(&session_id);
self.pending_thread_ops_tx
.try_send(DbOperation::Delete(session_id))
.log_err();
+ cx.notify();
}
fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
@@ -397,7 +391,7 @@ impl ThreadMetadataStore {
weak_store
.update(cx, |this, cx| {
- let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update);
+ let subscription = cx.subscribe(&thread_entity, Self::handle_thread_event);
this.session_subscriptions
.insert(thread.session_id().clone(), subscription);
})
@@ -406,9 +400,9 @@ impl ThreadMetadataStore {
.detach();
let (tx, rx) = smol::channel::unbounded();
- let _db_operations_task = cx.spawn({
+ let _db_operations_task = cx.background_spawn({
let db = db.clone();
- async move |this, cx| {
+ async move {
while let Ok(first_update) = rx.recv().await {
let mut updates = vec![first_update];
while let Ok(update) = rx.try_recv() {
@@ -417,7 +411,7 @@ impl ThreadMetadataStore {
let updates = Self::dedup_db_operations(updates);
for operation in updates {
match operation {
- DbOperation::Insert(metadata) => {
+ DbOperation::Upsert(metadata) => {
db.save(metadata).await.log_err();
}
DbOperation::Delete(session_id) => {
@@ -425,8 +419,6 @@ impl ThreadMetadataStore {
}
}
}
-
- this.update(cx, |this, cx| this.reload(cx)).ok();
}
}
});
@@ -455,10 +447,10 @@ impl ThreadMetadataStore {
ops.into_values().collect()
}
- fn handle_thread_update(
+ fn handle_thread_event(
&mut self,
thread: Entity<acp_thread::AcpThread>,
- event: &acp_thread::AcpThreadEvent,
+ event: &AcpThreadEvent,
cx: &mut Context<Self>,
) {
// Don't track subagent threads in the sidebar.
@@ -467,26 +459,62 @@ impl ThreadMetadataStore {
}
match event {
- acp_thread::AcpThreadEvent::NewEntry
- | acp_thread::AcpThreadEvent::TitleUpdated
- | acp_thread::AcpThreadEvent::EntryUpdated(_)
- | acp_thread::AcpThreadEvent::EntriesRemoved(_)
- | acp_thread::AcpThreadEvent::ToolAuthorizationRequested(_)
- | acp_thread::AcpThreadEvent::ToolAuthorizationReceived(_)
- | acp_thread::AcpThreadEvent::Retry(_)
- | acp_thread::AcpThreadEvent::Stopped(_)
- | acp_thread::AcpThreadEvent::Error
- | acp_thread::AcpThreadEvent::LoadError(_)
- | acp_thread::AcpThreadEvent::Refusal => {
- let is_archived = self
- .threads
- .get(thread.read(cx).session_id())
- .map(|t| t.archived)
- .unwrap_or(false);
- let metadata = ThreadMetadata::from_thread(is_archived, &thread, cx);
+ AcpThreadEvent::NewEntry
+ | AcpThreadEvent::TitleUpdated
+ | AcpThreadEvent::EntryUpdated(_)
+ | AcpThreadEvent::EntriesRemoved(_)
+ | AcpThreadEvent::ToolAuthorizationRequested(_)
+ | AcpThreadEvent::ToolAuthorizationReceived(_)
+ | AcpThreadEvent::Retry(_)
+ | AcpThreadEvent::Stopped(_)
+ | AcpThreadEvent::Error
+ | AcpThreadEvent::LoadError(_)
+ | AcpThreadEvent::Refusal
+ | AcpThreadEvent::WorkingDirectoriesUpdated => {
+ let thread_ref = thread.read(cx);
+ let existing_thread = self.threads.get(thread_ref.session_id());
+ let session_id = thread_ref.session_id().clone();
+ let title = thread_ref
+ .title()
+ .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
+
+ let updated_at = Utc::now();
+
+ let created_at = existing_thread
+ .and_then(|t| t.created_at)
+ .unwrap_or_else(|| updated_at);
+
+ let agent_id = thread_ref.connection().agent_id();
+
+ let folder_paths = {
+ let project = thread_ref.project().read(cx);
+ let paths: Vec<Arc<Path>> = project
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ .collect();
+ PathList::new(&paths)
+ };
+
+ let archived = existing_thread.map(|t| t.archived).unwrap_or(false);
+
+ let metadata = ThreadMetadata {
+ session_id,
+ agent_id,
+ title,
+ created_at: Some(created_at),
+ updated_at,
+ folder_paths,
+ archived,
+ };
+
self.save(metadata, cx);
}
- _ => {}
+ AcpThreadEvent::TokenUsageUpdated
+ | AcpThreadEvent::SubagentSpawned(_)
+ | AcpThreadEvent::PromptCapabilitiesUpdated
+ | AcpThreadEvent::AvailableCommandsUpdated(_)
+ | AcpThreadEvent::ModeUpdated(_)
+ | AcpThreadEvent::ConfigOptionsUpdated(_) => {}
}
}
}
@@ -559,6 +587,7 @@ impl ThreadMetadataDb {
agent_id = excluded.agent_id, \
title = excluded.title, \
updated_at = excluded.updated_at, \
+ created_at = excluded.created_at, \
folder_paths = excluded.folder_paths, \
folder_paths_order = excluded.folder_paths_order, \
archived = excluded.archived";
@@ -688,6 +717,17 @@ mod tests {
}
}
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = settings::SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ cx.update_flags(true, vec!["agent-v2".to_string()]);
+ ThreadMetadataStore::init_global(cx);
+ ThreadStore::init_global(cx);
+ });
+ cx.run_until_parked();
+ }
+
#[gpui::test]
async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
let first_paths = PathList::new(&[Path::new("/project-a")]);
@@ -756,12 +796,7 @@ mod tests {
#[gpui::test]
async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let first_paths = PathList::new(&[Path::new("/project-a")]);
let second_paths = PathList::new(&[Path::new("/project-b")]);
@@ -881,10 +916,7 @@ mod tests {
#[gpui::test]
async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
- cx.update(|cx| {
- ThreadStore::init_global(cx);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let project_a_paths = PathList::new(&[Path::new("/project-a")]);
let project_b_paths = PathList::new(&[Path::new("/project-b")]);
@@ -959,7 +991,7 @@ mod tests {
let list = cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
- store.read(cx).entries().collect::<Vec<_>>()
+ store.read(cx).entries().cloned().collect::<Vec<_>>()
});
assert_eq!(list.len(), 3);
@@ -999,10 +1031,7 @@ mod tests {
async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
cx: &mut TestAppContext,
) {
- cx.update(|cx| {
- ThreadStore::init_global(cx);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let project_paths = PathList::new(&[Path::new("/project-a")]);
let existing_updated_at = Utc::now();
@@ -1047,7 +1076,7 @@ mod tests {
let list = cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
- store.read(cx).entries().collect::<Vec<_>>()
+ store.read(cx).entries().cloned().collect::<Vec<_>>()
});
assert_eq!(list.len(), 1);
@@ -1058,10 +1087,7 @@ mod tests {
async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
cx: &mut TestAppContext,
) {
- cx.update(|cx| {
- ThreadStore::init_global(cx);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let project_a_paths = PathList::new(&[Path::new("/project-a")]);
let project_b_paths = PathList::new(&[Path::new("/project-b")]);
@@ -1110,7 +1136,7 @@ mod tests {
let list = cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
- store.read(cx).entries().collect::<Vec<_>>()
+ store.read(cx).entries().cloned().collect::<Vec<_>>()
});
assert_eq!(list.len(), 10);
@@ -1149,13 +1175,7 @@ mod tests {
#[gpui::test]
async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadStore::init_global(cx);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None::<&Path>, cx).await;
@@ -1205,13 +1225,7 @@ mod tests {
#[gpui::test]
async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadStore::init_global(cx);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None::<&Path>, cx).await;
@@ -1257,13 +1271,7 @@ mod tests {
#[gpui::test]
async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadStore::init_global(cx);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None::<&Path>, cx).await;
@@ -1321,7 +1329,7 @@ mod tests {
// List all metadata from the store cache.
let list = cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
- store.read(cx).entries().collect::<Vec<_>>()
+ store.read(cx).entries().cloned().collect::<Vec<_>>()
});
// The subagent thread should NOT appear in the sidebar metadata.
@@ -1342,7 +1350,7 @@ mod tests {
let now = Utc::now();
let operations = vec![
- DbOperation::Insert(make_metadata(
+ DbOperation::Upsert(make_metadata(
"session-1",
"First Thread",
now,
@@ -1369,12 +1377,12 @@ mod tests {
let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
let deduped = ThreadMetadataStore::dedup_db_operations(vec![
- DbOperation::Insert(old_metadata),
- DbOperation::Insert(new_metadata.clone()),
+ DbOperation::Upsert(old_metadata),
+ DbOperation::Upsert(new_metadata.clone()),
]);
assert_eq!(deduped.len(), 1);
- assert_eq!(deduped[0], DbOperation::Insert(new_metadata));
+ assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
}
#[test]
@@ -1384,23 +1392,18 @@ mod tests {
let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
let deduped = ThreadMetadataStore::dedup_db_operations(vec![
- DbOperation::Insert(metadata1.clone()),
- DbOperation::Insert(metadata2.clone()),
+ DbOperation::Upsert(metadata1.clone()),
+ DbOperation::Upsert(metadata2.clone()),
]);
assert_eq!(deduped.len(), 2);
- assert!(deduped.contains(&DbOperation::Insert(metadata1)));
- assert!(deduped.contains(&DbOperation::Insert(metadata2)));
+ assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
+ assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
}
#[gpui::test]
async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let paths = PathList::new(&[Path::new("/project-a")]);
let now = Utc::now();
@@ -1486,12 +1489,7 @@ mod tests {
#[gpui::test]
async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let paths = PathList::new(&[Path::new("/project-a")]);
let now = Utc::now();
@@ -1551,12 +1549,7 @@ mod tests {
#[gpui::test]
async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let paths = PathList::new(&[Path::new("/project-a")]);
let now = Utc::now();
@@ -1604,12 +1597,7 @@ mod tests {
#[gpui::test]
async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
let paths = PathList::new(&[Path::new("/project-a")]);
let now = Utc::now();
@@ -1668,12 +1656,7 @@ mod tests {
#[gpui::test]
async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
- ThreadMetadataStore::init_global(cx);
- });
+ init_test(cx);
cx.run_until_parked();
@@ -1695,4 +1678,38 @@ mod tests {
assert_eq!(store.archived_entries().count(), 0);
});
}
+
+ #[gpui::test]
+ async fn test_save_followed_by_archiving_without_parking(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let paths = PathList::new(&[Path::new("/project-a")]);
+ let now = Utc::now();
+ let metadata = make_metadata("session-1", "Thread 1", now, paths);
+ let session_id = metadata.session_id.clone();
+
+ cx.update(|cx| {
+ let store = ThreadMetadataStore::global(cx);
+ store.update(cx, |store, cx| {
+ store.save(metadata.clone(), cx);
+ store.archive(&session_id, cx);
+ });
+ });
+
+ cx.run_until_parked();
+
+ cx.update(|cx| {
+ let store = ThreadMetadataStore::global(cx);
+ let store = store.read(cx);
+
+ let entries: Vec<ThreadMetadata> = store.entries().cloned().collect();
+ pretty_assertions::assert_eq!(
+ entries,
+ vec![ThreadMetadata {
+ archived: true,
+ ..metadata
+ }]
+ );
+ });
+ }
}
@@ -92,10 +92,10 @@ async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::Vi
acp::SessionId::new(Arc::from(format!("thread-{}", i))),
format!("Thread {}", i + 1).into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
+ None,
path_list.clone(),
cx,
)
- .await;
}
cx.run_until_parked();
}
@@ -109,10 +109,10 @@ async fn save_test_thread_metadata(
session_id.clone(),
"Test".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+ None,
path_list,
cx,
)
- .await;
}
async fn save_named_thread_metadata(
@@ -125,17 +125,18 @@ async fn save_named_thread_metadata(
acp::SessionId::new(Arc::from(session_id)),
SharedString::from(title.to_string()),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
- )
- .await;
+ );
cx.run_until_parked();
}
-async fn save_thread_metadata(
+fn save_thread_metadata(
session_id: acp::SessionId,
title: SharedString,
updated_at: DateTime<Utc>,
+ created_at: Option<DateTime<Utc>>,
path_list: PathList,
cx: &mut TestAppContext,
) {
@@ -144,12 +145,12 @@ async fn save_thread_metadata(
agent_id: agent::ZED_AGENT_ID.clone(),
title,
updated_at,
- created_at: None,
+ created_at,
folder_paths: path_list,
archived: false,
};
cx.update(|cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx))
});
cx.run_until_parked();
}
@@ -407,19 +408,19 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
acp::SessionId::new(Arc::from("thread-1")),
"Fix crash in project panel".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
- )
- .await;
+ );
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-2")),
"Add inline diff view".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -449,10 +450,10 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
acp::SessionId::new(Arc::from("thread-a1")),
"Thread A1".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -1331,10 +1332,10 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
- )
- .await;
+ );
}
cx.run_until_parked();
@@ -1379,10 +1380,10 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
acp::SessionId::new(Arc::from("thread-1")),
"Fix Crash In Project Panel".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
// Lowercase query matches mixed-case title.
@@ -1422,10 +1423,10 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
)
- .await;
}
cx.run_until_parked();
@@ -1474,10 +1475,10 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list_a.clone(),
cx,
)
- .await;
}
// Add a second workspace.
@@ -1496,10 +1497,10 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list_b.clone(),
cx,
)
- .await;
}
cx.run_until_parked();
@@ -1556,10 +1557,10 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list_a.clone(),
cx,
)
- .await;
}
// Add a second workspace.
@@ -1578,10 +1579,10 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list_b.clone(),
cx,
)
- .await;
}
cx.run_until_parked();
@@ -1662,10 +1663,10 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
acp::SessionId::new(Arc::from(format!("thread-{}", i))),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
+ None,
path_list.clone(),
cx,
)
- .await;
}
cx.run_until_parked();
@@ -1706,10 +1707,10 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte
acp::SessionId::new(Arc::from("thread-1")),
"Important thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
// User focuses the sidebar and collapses the group using keyboard:
@@ -1752,10 +1753,10 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
)
- .await;
}
cx.run_until_parked();
@@ -1814,10 +1815,10 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
acp::SessionId::new(Arc::from("hist-1")),
"Historical Thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1867,19 +1868,19 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo
acp::SessionId::new(Arc::from("t-1")),
"Thread A".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
- )
- .await;
+ );
save_thread_metadata(
acp::SessionId::new(Arc::from("t-2")),
"Thread B".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -4226,22 +4227,22 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
thread2_session_id.clone(),
"Thread 2".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+ None,
PathList::new(&[std::path::PathBuf::from("/project")]),
cx,
- )
- .await;
+ );
// Save thread 1's metadata with the worktree path and an older timestamp so
// it sorts below thread 2. archive_thread will find it as the "next" candidate.
let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
save_thread_metadata(
- thread1_session_id.clone(),
+ thread1_session_id,
"Thread 1".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+ None,
PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
cx,
- )
- .await;
+ );
cx.run_until_parked();
@@ -4439,26 +4440,14 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
open_thread_with_connection(&panel, connection_c, cx);
send_message(&panel, cx);
let session_id_c = active_session_id(&panel, cx);
- cx.update(|_, cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.save(
- ThreadMetadata {
- session_id: session_id_c.clone(),
- agent_id: agent::ZED_AGENT_ID.clone(),
- title: "Thread C".into(),
- updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0)
- .unwrap(),
- created_at: Some(
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- ),
- folder_paths: path_list.clone(),
- archived: false,
- },
- cx,
- )
- })
- });
- cx.run_until_parked();
+ save_thread_metadata(
+ session_id_c.clone(),
+ "Thread C".into(),
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+ Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
+ path_list.clone(),
+ cx,
+ );
let connection_b = StubAgentConnection::new();
connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -4467,26 +4456,14 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
open_thread_with_connection(&panel, connection_b, cx);
send_message(&panel, cx);
let session_id_b = active_session_id(&panel, cx);
- cx.update(|_, cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.save(
- ThreadMetadata {
- session_id: session_id_b.clone(),
- agent_id: agent::ZED_AGENT_ID.clone(),
- title: "Thread B".into(),
- updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0)
- .unwrap(),
- created_at: Some(
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
- ),
- folder_paths: path_list.clone(),
- archived: false,
- },
- cx,
- )
- })
- });
- cx.run_until_parked();
+ save_thread_metadata(
+ session_id_b.clone(),
+ "Thread B".into(),
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+ Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
+ path_list.clone(),
+ cx,
+ );
let connection_a = StubAgentConnection::new();
connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -4495,26 +4472,14 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
open_thread_with_connection(&panel, connection_a, cx);
send_message(&panel, cx);
let session_id_a = active_session_id(&panel, cx);
- cx.update(|_, cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.save(
- ThreadMetadata {
- session_id: session_id_a.clone(),
- agent_id: agent::ZED_AGENT_ID.clone(),
- title: "Thread A".into(),
- updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0)
- .unwrap(),
- created_at: Some(
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
- ),
- folder_paths: path_list.clone(),
- archived: false,
- },
- cx,
- )
- })
- });
- cx.run_until_parked();
+ save_thread_metadata(
+ session_id_a.clone(),
+ "Thread A".into(),
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
+ Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
+ path_list.clone(),
+ cx,
+ );
// All three threads are now live. Thread A was opened last, so it's
// the one being viewed. Opening each thread called record_thread_access,
@@ -4569,6 +4534,8 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
cx.run_until_parked();
assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
+ assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
+
// Confirm on Thread C.
sidebar.update_in(cx, |sidebar, window, cx| {
let switcher = sidebar.thread_switcher.as_ref().unwrap();
@@ -4585,7 +4552,23 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
);
});
- // Re-open switcher: Thread C is now most-recently-accessed.
+ sidebar.update(cx, |sidebar, _cx| {
+ let last_accessed = sidebar
+ .thread_last_accessed
+ .keys()
+ .cloned()
+ .collect::<Vec<_>>();
+ assert_eq!(last_accessed.len(), 1);
+ assert!(last_accessed.contains(&session_id_c));
+ assert!(
+ sidebar
+ .active_entry
+ .as_ref()
+ .expect("active_entry should be set")
+ .is_active_thread(&session_id_c)
+ );
+ });
+
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
});
@@ -4600,34 +4583,90 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
],
);
+ // Confirm on Thread A.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ let switcher = sidebar.thread_switcher.as_ref().unwrap();
+ let focus = switcher.focus_handle(cx);
+ focus.dispatch_action(&menu::Confirm, window, cx);
+ });
+ cx.run_until_parked();
+
+ sidebar.update(cx, |sidebar, _cx| {
+ let last_accessed = sidebar
+ .thread_last_accessed
+ .keys()
+ .cloned()
+ .collect::<Vec<_>>();
+ assert_eq!(last_accessed.len(), 2);
+ assert!(last_accessed.contains(&session_id_c));
+ assert!(last_accessed.contains(&session_id_a));
+ assert!(
+ sidebar
+ .active_entry
+ .as_ref()
+ .expect("active_entry should be set")
+ .is_active_thread(&session_id_a)
+ );
+ });
+
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ switcher_ids(&sidebar, cx),
+ vec![
+ session_id_a.clone(),
+ session_id_c.clone(),
+ session_id_b.clone(),
+ ],
+ );
+
sidebar.update_in(cx, |sidebar, _window, cx| {
- sidebar.dismiss_thread_switcher(cx);
+ let switcher = sidebar.thread_switcher.as_ref().unwrap();
+ switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
});
cx.run_until_parked();
- // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
- // This thread was never opened in a panel — it only exists in metadata.
- cx.update(|_, cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.save(
- ThreadMetadata {
- session_id: acp::SessionId::new(Arc::from("thread-historical")),
- agent_id: agent::ZED_AGENT_ID.clone(),
- title: "Historical Thread".into(),
- updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0)
- .unwrap(),
- created_at: Some(
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
- ),
- folder_paths: path_list.clone(),
- archived: false,
- },
- cx,
- )
- })
+ // Confirm on Thread B.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ let switcher = sidebar.thread_switcher.as_ref().unwrap();
+ let focus = switcher.focus_handle(cx);
+ focus.dispatch_action(&menu::Confirm, window, cx);
});
cx.run_until_parked();
+ sidebar.update(cx, |sidebar, _cx| {
+ let last_accessed = sidebar
+ .thread_last_accessed
+ .keys()
+ .cloned()
+ .collect::<Vec<_>>();
+ assert_eq!(last_accessed.len(), 3);
+ assert!(last_accessed.contains(&session_id_c));
+ assert!(last_accessed.contains(&session_id_a));
+ assert!(last_accessed.contains(&session_id_b));
+ assert!(
+ sidebar
+ .active_entry
+ .as_ref()
+ .expect("active_entry should be set")
+ .is_active_thread(&session_id_b)
+ );
+ });
+
+ // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
+ // This thread was never opened in a panel — it only exists in metadata.
+ save_thread_metadata(
+ acp::SessionId::new(Arc::from("thread-historical")),
+ "Historical Thread".into(),
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
+ Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
+ path_list.clone(),
+ cx,
+ );
+
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
});
@@ -4642,13 +4681,14 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
// last_message_sent_or_queued. So for the accessed threads (tier 1) the
// sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
+
let ids = switcher_ids(&sidebar, cx);
assert_eq!(
ids,
vec![
- session_id_c.clone(),
- session_id_a.clone(),
session_id_b.clone(),
+ session_id_a.clone(),
+ session_id_c.clone(),
session_id_hist.clone()
],
);
@@ -4659,26 +4699,14 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
cx.run_until_parked();
// ── 4. Add another historical thread with older created_at ─────────
- cx.update(|_, cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.save(
- ThreadMetadata {
- session_id: acp::SessionId::new(Arc::from("thread-old-historical")),
- agent_id: agent::ZED_AGENT_ID.clone(),
- title: "Old Historical Thread".into(),
- updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0)
- .unwrap(),
- created_at: Some(
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
- ),
- folder_paths: path_list.clone(),
- archived: false,
- },
- cx,
- )
- })
- });
- cx.run_until_parked();
+ save_thread_metadata(
+ acp::SessionId::new(Arc::from("thread-old-historical")),
+ "Old Historical Thread".into(),
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
+ Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
+ path_list,
+ cx,
+ );
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
@@ -4692,9 +4720,9 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
assert_eq!(
ids,
vec![
- session_id_c.clone(),
- session_id_a.clone(),
- session_id_b.clone(),
+ session_id_b,
+ session_id_a,
+ session_id_c,
session_id_hist,
session_id_old_hist,
],
@@ -4719,10 +4747,10 @@ async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut Test
acp::SessionId::new(Arc::from("thread-to-archive")),
"Thread To Archive".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- path_list.clone(),
+ None,
+ path_list,
cx,
- )
- .await;
+ );
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -4771,22 +4799,25 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon
acp::SessionId::new(Arc::from("visible-thread")),
"Visible Thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+ None,
path_list.clone(),
cx,
- )
- .await;
+ );
+
+ let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
+ save_thread_metadata(
+ archived_thread_session_id.clone(),
+ "Archived Thread".into(),
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+ None,
+ path_list,
+ cx,
+ );
cx.update(|_, cx| {
- let metadata = ThreadMetadata {
- session_id: acp::SessionId::new(Arc::from("archived-thread")),
- agent_id: agent::ZED_AGENT_ID.clone(),
- title: "Archived Thread".into(),
- updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- created_at: None,
- folder_paths: path_list.clone(),
- archived: true,
- };
- ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.archive(&archived_thread_session_id, cx)
+ })
});
cx.run_until_parked();
@@ -4962,18 +4993,7 @@ mod property_test {
let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
.unwrap()
+ chrono::Duration::seconds(state.thread_counter as i64);
- let metadata = ThreadMetadata {
- session_id,
- agent_id: agent::ZED_AGENT_ID.clone(),
- title,
- updated_at,
- created_at: None,
- folder_paths: path_list,
- archived: false,
- };
- cx.update(|_, cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
- });
+ save_thread_metadata(session_id, title, updated_at, None, path_list, cx);
}
async fn perform_operation(