mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
If we have empty drafts, they don't show up in the UI, so you can't get rid of them. But they currently blocked worktree archival. Which is particularly troublesome with terminal agents in a few cases. This should hopefully solve the issue for terminals, but I think I need to do a follow-up to investigate what happens when the last draft is closed. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A
4219 lines
152 KiB
Rust
4219 lines
152 KiB
Rust
use std::{
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
|
|
use agent::{ThreadStore, ZED_AGENT_ID};
|
|
use agent_client_protocol::schema as acp;
|
|
use anyhow::Context as _;
|
|
use chrono::{DateTime, Utc};
|
|
use collections::{HashMap, HashSet};
|
|
use db::{
|
|
kvp::KeyValueStore,
|
|
sqlez::{
|
|
bindable::{Bind, Column},
|
|
domain::Domain,
|
|
statement::Statement,
|
|
thread_safe_connection::ThreadSafeConnection,
|
|
},
|
|
sqlez_macros::sql,
|
|
};
|
|
use fs::Fs;
|
|
use futures::{FutureExt, future::Shared};
|
|
use gpui::{AppContext as _, Entity, Global, Subscription, Task, TaskExt};
|
|
pub use project::WorktreePaths;
|
|
use project::{AgentId, linked_worktree_short_name};
|
|
use remote::{RemoteConnectionOptions, same_remote_connection_identity};
|
|
use ui::{App, Context, SharedString, ThreadItemWorktreeInfo, WorktreeKind};
|
|
use util::ResultExt as _;
|
|
use workspace::{PathList, SerializedWorkspaceLocation, WorkspaceDb};
|
|
|
|
use crate::DEFAULT_THREAD_TITLE;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct ThreadId(uuid::Uuid);
|
|
|
|
impl ThreadId {
|
|
pub fn new() -> Self {
|
|
Self(uuid::Uuid::new_v4())
|
|
}
|
|
|
|
/// Stable, hyphenated string form suitable for use as a key.
|
|
pub fn to_key_string(&self) -> String {
|
|
self.0.hyphenated().to_string()
|
|
}
|
|
}
|
|
|
|
impl Bind for ThreadId {
|
|
fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
|
|
self.0.bind(statement, start_index)
|
|
}
|
|
}
|
|
|
|
impl Column for ThreadId {
|
|
fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
|
|
let (uuid, next) = Column::column(statement, start_index)?;
|
|
Ok((ThreadId(uuid), next))
|
|
}
|
|
}
|
|
|
|
const THREAD_REMOTE_CONNECTION_MIGRATION_KEY: &str = "thread-metadata-remote-connection-backfill";
|
|
const THREAD_ID_MIGRATION_KEY: &str = "thread-metadata-thread-id-backfill";
|
|
|
|
/// List all sidebar thread metadata from an arbitrary SQLite connection.
|
|
///
|
|
/// This is used to read thread metadata from another release channel's
|
|
/// database without opening a full `ThreadSafeConnection`.
|
|
pub(crate) fn list_thread_metadata_from_connection(
|
|
connection: &db::sqlez::connection::Connection,
|
|
) -> anyhow::Result<Vec<ThreadMetadata>> {
|
|
connection.select::<ThreadMetadata>(ThreadMetadataDb::LIST_QUERY)?()
|
|
}
|
|
|
|
/// Run the `ThreadMetadataDb` migrations on a raw connection.
|
|
///
|
|
/// This is used in tests to set up the sidebar_threads schema in a
|
|
/// temporary database.
|
|
#[cfg(test)]
|
|
pub(crate) fn run_thread_metadata_migrations(connection: &db::sqlez::connection::Connection) {
|
|
connection
|
|
.migrate(
|
|
ThreadMetadataDb::NAME,
|
|
ThreadMetadataDb::MIGRATIONS,
|
|
&mut |_, _, _| false,
|
|
)
|
|
.expect("thread metadata migrations should succeed");
|
|
}
|
|
|
|
pub fn init(cx: &mut App) {
|
|
ThreadMetadataStore::init_global(cx);
|
|
let migration_task = migrate_thread_metadata(cx);
|
|
migrate_thread_remote_connections(cx, migration_task);
|
|
migrate_thread_ids(cx);
|
|
}
|
|
|
|
/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
|
|
/// We skip migrating threads that do not have a project.
|
|
///
|
|
/// TODO: Remove this after N weeks of shipping the sidebar
|
|
fn migrate_thread_metadata(cx: &mut App) -> Task<anyhow::Result<()>> {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let db = store.read(cx).db.clone();
|
|
let thread_store = ThreadStore::global(cx);
|
|
let thread_store_ready = thread_store.read(cx).reload_task();
|
|
|
|
cx.spawn(async move |cx| {
|
|
// Wait for `ThreadStore`'s initial reload to complete. Without this,
|
|
// reading `entries()` races with the store's async population from
|
|
// disk and usually observes an empty iterator, silently skipping the
|
|
// migration on every launch. The regression test
|
|
// `test_migration_awaits_thread_store_reload` pins this behavior.
|
|
thread_store_ready.await;
|
|
|
|
let existing_list = db.list()?;
|
|
let existing_session_ids: HashSet<Arc<str>> = existing_list
|
|
.into_iter()
|
|
.filter_map(|m| m.session_id.map(|s| s.0))
|
|
.collect();
|
|
|
|
let mut to_migrate = thread_store.read_with(cx, |store, _cx| {
|
|
store
|
|
.entries()
|
|
.filter_map(|entry| {
|
|
if existing_session_ids.contains(&entry.id.0) {
|
|
return None;
|
|
}
|
|
|
|
Some(ThreadMetadata {
|
|
thread_id: ThreadId::new(),
|
|
session_id: Some(entry.id),
|
|
agent_id: ZED_AGENT_ID.clone(),
|
|
title: if entry.title.is_empty()
|
|
|| entry.title.as_ref() == DEFAULT_THREAD_TITLE
|
|
{
|
|
None
|
|
} else {
|
|
Some(entry.title)
|
|
},
|
|
title_override: None,
|
|
updated_at: entry.updated_at,
|
|
created_at: entry.created_at,
|
|
interacted_at: None,
|
|
worktree_paths: WorktreePaths::from_folder_paths(&entry.folder_paths),
|
|
remote_connection: None,
|
|
archived: true,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
if to_migrate.is_empty() {
|
|
return anyhow::Ok(());
|
|
}
|
|
|
|
// For each batch of newly-migrated threads, keep the 5 most recent
|
|
// per project unarchived. Previously this was gated on
|
|
// `is_first_migration` (an empty `sidebar_threads`), which meant any
|
|
// subsequent batch of newly-discovered legacy threads got migrated as
|
|
// fully archived. Running the rescue per-batch keeps the behavior
|
|
// idempotent across partial migrations and re-runs.
|
|
let mut per_project: HashMap<PathList, Vec<&mut ThreadMetadata>> = HashMap::default();
|
|
for entry in &mut to_migrate {
|
|
if entry.worktree_paths.is_empty() {
|
|
continue;
|
|
}
|
|
per_project
|
|
.entry(entry.worktree_paths.folder_path_list().clone())
|
|
.or_default()
|
|
.push(entry);
|
|
}
|
|
for entries in per_project.values_mut() {
|
|
entries.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
|
|
for entry in entries.iter_mut().take(5) {
|
|
entry.archived = false;
|
|
}
|
|
}
|
|
|
|
log::info!("Migrating {} thread store entries", to_migrate.len());
|
|
|
|
// Manually save each entry to the database and call reload, otherwise
|
|
// we'll end up triggering lots of reloads after each save
|
|
for entry in to_migrate {
|
|
db.save(entry).await?;
|
|
}
|
|
|
|
log::info!("Finished migrating thread store entries");
|
|
|
|
let _ = store.update(cx, |store, cx| store.reload(cx));
|
|
anyhow::Ok(())
|
|
})
|
|
}
|
|
|
|
fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::Result<()>>) {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let db = store.read(cx).db.clone();
|
|
let kvp = KeyValueStore::global(cx);
|
|
let workspace_db = WorkspaceDb::global(cx);
|
|
let fs = <dyn Fs>::global(cx);
|
|
|
|
cx.spawn(async move |cx| -> anyhow::Result<()> {
|
|
migration_task.await?;
|
|
|
|
if kvp
|
|
.read_kvp(THREAD_REMOTE_CONNECTION_MIGRATION_KEY)?
|
|
.is_some()
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
let recent_workspaces = workspace_db
|
|
.recent_project_workspaces_ungrouped(fs.as_ref())
|
|
.await?;
|
|
|
|
let mut local_path_lists = HashSet::<PathList>::default();
|
|
let mut remote_path_lists = HashMap::<PathList, RemoteConnectionOptions>::default();
|
|
|
|
recent_workspaces
|
|
.iter()
|
|
.filter(|workspace| {
|
|
!workspace.paths.is_empty()
|
|
&& matches!(workspace.location, SerializedWorkspaceLocation::Local)
|
|
})
|
|
.for_each(|workspace| {
|
|
local_path_lists.insert(workspace.paths.clone());
|
|
});
|
|
|
|
for workspace in recent_workspaces {
|
|
match workspace.location {
|
|
SerializedWorkspaceLocation::Remote(remote_connection)
|
|
if !local_path_lists.contains(&workspace.paths) =>
|
|
{
|
|
remote_path_lists
|
|
.entry(workspace.paths)
|
|
.or_insert(remote_connection);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let mut reloaded = false;
|
|
for metadata in db.list()? {
|
|
if metadata.remote_connection.is_some() {
|
|
continue;
|
|
}
|
|
|
|
if let Some(remote_connection) = remote_path_lists
|
|
.get(metadata.folder_paths())
|
|
.or_else(|| remote_path_lists.get(metadata.main_worktree_paths()))
|
|
{
|
|
db.save(ThreadMetadata {
|
|
remote_connection: Some(remote_connection.clone()),
|
|
..metadata
|
|
})
|
|
.await?;
|
|
reloaded = true;
|
|
}
|
|
}
|
|
|
|
let reloaded_task = reloaded
|
|
.then_some(store.update(cx, |store, cx| store.reload(cx)))
|
|
.unwrap_or(Task::ready(()).shared());
|
|
|
|
kvp.write_kvp(
|
|
THREAD_REMOTE_CONNECTION_MIGRATION_KEY.to_string(),
|
|
"1".to_string(),
|
|
)
|
|
.await?;
|
|
reloaded_task.await;
|
|
|
|
Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn migrate_thread_ids(cx: &mut App) {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let db = store.read(cx).db.clone();
|
|
let kvp = KeyValueStore::global(cx);
|
|
|
|
cx.spawn(async move |cx| -> anyhow::Result<()> {
|
|
if kvp.read_kvp(THREAD_ID_MIGRATION_KEY)?.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut reloaded = false;
|
|
for metadata in db.list()? {
|
|
db.save(metadata).await?;
|
|
reloaded = true;
|
|
}
|
|
|
|
let reloaded_task = reloaded
|
|
.then_some(store.update(cx, |store, cx| store.reload(cx)))
|
|
.unwrap_or(Task::ready(()).shared());
|
|
|
|
kvp.write_kvp(THREAD_ID_MIGRATION_KEY.to_string(), "1".to_string())
|
|
.await?;
|
|
reloaded_task.await;
|
|
|
|
Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
|
|
impl Global for GlobalThreadMetadataStore {}
|
|
|
|
/// Lightweight metadata for any thread (native or ACP), enough to populate
|
|
/// the sidebar list and route to the correct load path when clicked.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ThreadMetadata {
|
|
pub thread_id: ThreadId,
|
|
pub session_id: Option<acp::SessionId>,
|
|
pub agent_id: AgentId,
|
|
pub title: Option<SharedString>,
|
|
/// User-supplied title that takes precedence over `title`. Set when the
|
|
/// user renames a thread, so that subsequent agent-driven title updates
|
|
/// (e.g. from `SessionInfoUpdate`) don't clobber the user's choice.
|
|
pub title_override: Option<SharedString>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub created_at: Option<DateTime<Utc>>,
|
|
/// When a user last interacted to send a message (including queueing).
|
|
/// Doesn't include the time when a queued message is fired.
|
|
pub interacted_at: Option<DateTime<Utc>>,
|
|
pub worktree_paths: WorktreePaths,
|
|
pub remote_connection: Option<RemoteConnectionOptions>,
|
|
pub archived: bool,
|
|
}
|
|
|
|
impl ThreadMetadata {
|
|
/// A thread is a draft until its first message is sent, at which point
|
|
/// it gets an ACP `session_id`.
|
|
pub fn is_draft(&self) -> bool {
|
|
self.session_id.is_none()
|
|
}
|
|
|
|
pub fn display_title(&self) -> SharedString {
|
|
self.title()
|
|
.unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into())
|
|
}
|
|
|
|
pub fn title(&self) -> Option<SharedString> {
|
|
self.title_override.clone().or_else(|| self.title.clone())
|
|
}
|
|
|
|
pub fn folder_paths(&self) -> &PathList {
|
|
self.worktree_paths.folder_path_list()
|
|
}
|
|
pub fn main_worktree_paths(&self) -> &PathList {
|
|
self.worktree_paths.main_worktree_path_list()
|
|
}
|
|
|
|
pub fn references_folder_path(&self, path: &Path) -> bool {
|
|
self.folder_paths()
|
|
.paths()
|
|
.iter()
|
|
.any(|folder_path| folder_path.as_path() == path)
|
|
}
|
|
|
|
pub fn matches_remote_connection(
|
|
&self,
|
|
remote_connection: Option<&RemoteConnectionOptions>,
|
|
) -> bool {
|
|
same_remote_connection_identity(self.remote_connection.as_ref(), remote_connection)
|
|
}
|
|
}
|
|
|
|
/// Derives worktree display info from a thread's stored path list.
|
|
///
|
|
/// For each path in the thread's `folder_paths`, produces a
|
|
/// [`ThreadItemWorktreeInfo`] with a short display name, full path, and whether
|
|
/// the worktree is the main checkout or a linked git worktree. When
|
|
/// multiple main paths exist and a linked worktree's short name alone
|
|
/// wouldn't identify which main project it belongs to, the main project
|
|
/// name is prefixed for disambiguation (e.g. `project:feature`).
|
|
pub fn worktree_info_from_thread_paths<S: std::hash::BuildHasher>(
|
|
worktree_paths: &WorktreePaths,
|
|
branch_names: &std::collections::HashMap<PathBuf, SharedString, S>,
|
|
) -> Vec<ThreadItemWorktreeInfo> {
|
|
let mut infos: Vec<ThreadItemWorktreeInfo> = Vec::new();
|
|
let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
|
|
let mut unique_main_count = HashSet::default();
|
|
|
|
for (main_path, folder_path) in worktree_paths.ordered_pairs() {
|
|
unique_main_count.insert(main_path.clone());
|
|
let is_linked = main_path != folder_path;
|
|
|
|
if is_linked {
|
|
let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
|
|
let project_name = main_path
|
|
.file_name()
|
|
.map(|n| SharedString::from(n.to_string_lossy().to_string()))
|
|
.unwrap_or_default();
|
|
linked_short_names.push((short_name.clone(), project_name));
|
|
infos.push(ThreadItemWorktreeInfo {
|
|
worktree_name: Some(short_name),
|
|
full_path: SharedString::from(folder_path.display().to_string()),
|
|
highlight_positions: Vec::new(),
|
|
kind: WorktreeKind::Linked,
|
|
branch_name: branch_names.get(folder_path).cloned(),
|
|
});
|
|
} else {
|
|
let Some(name) = folder_path.file_name() else {
|
|
continue;
|
|
};
|
|
infos.push(ThreadItemWorktreeInfo {
|
|
worktree_name: Some(SharedString::from(name.to_string_lossy().to_string())),
|
|
full_path: SharedString::from(folder_path.display().to_string()),
|
|
highlight_positions: Vec::new(),
|
|
kind: WorktreeKind::Main,
|
|
branch_name: branch_names.get(folder_path).cloned(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// When the group has multiple main worktree paths and the thread's
|
|
// folder paths don't all share the same short name, prefix each
|
|
// linked worktree chip with its main project name so the user knows
|
|
// which project it belongs to.
|
|
let all_same_name = infos.len() > 1
|
|
&& infos
|
|
.iter()
|
|
.all(|i| i.worktree_name == infos[0].worktree_name);
|
|
|
|
if unique_main_count.len() > 1 && !all_same_name {
|
|
for (info, (_short_name, project_name)) in infos
|
|
.iter_mut()
|
|
.filter(|i| i.kind == WorktreeKind::Linked)
|
|
.zip(linked_short_names.iter())
|
|
{
|
|
if let Some(name) = &info.worktree_name {
|
|
info.worktree_name = Some(SharedString::from(format!("{}:{}", project_name, name)));
|
|
}
|
|
}
|
|
}
|
|
|
|
infos
|
|
}
|
|
|
|
impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
|
|
fn from(meta: &ThreadMetadata) -> Self {
|
|
let session_id = meta
|
|
.session_id
|
|
.clone()
|
|
.unwrap_or_else(|| acp::SessionId::new(meta.thread_id.0.to_string()));
|
|
Self {
|
|
session_id,
|
|
work_dirs: Some(meta.folder_paths().clone()),
|
|
title: meta.title(),
|
|
updated_at: Some(meta.updated_at),
|
|
created_at: meta.created_at,
|
|
meta: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record of a git worktree that was archived (deleted from disk) when its
|
|
/// last thread was archived.
|
|
pub struct ArchivedGitWorktree {
|
|
/// Auto-incrementing primary key.
|
|
pub id: i64,
|
|
/// Absolute path to the directory of the worktree before it was deleted.
|
|
/// Used when restoring, to put the recreated worktree back where it was.
|
|
/// If the path already exists on disk, the worktree is assumed to be
|
|
/// already restored and is used as-is.
|
|
pub worktree_path: PathBuf,
|
|
/// Absolute path of the main repository ("main worktree") that owned this worktree.
|
|
/// Used when restoring, to reattach the recreated worktree to the correct main repo.
|
|
/// If the main repo isn't found on disk, unarchiving fails because we only store
|
|
/// commit hashes, and without the actual git repo being available, we can't restore
|
|
/// the files.
|
|
pub main_repo_path: PathBuf,
|
|
/// Branch that was checked out in the worktree at archive time. `None` if
|
|
/// the worktree was in detached HEAD state, which isn't supported in Zed, but
|
|
/// could happen if the user made a detached one outside of Zed.
|
|
/// On restore, we try to switch to this branch. If that fails (e.g. it's
|
|
/// checked out elsewhere), we auto-generate a new one.
|
|
pub branch_name: Option<String>,
|
|
/// SHA of the WIP commit that captures files that were staged (but not yet
|
|
/// committed) at the time of archiving. This commit can be empty if the
|
|
/// user had no staged files at the time. It sits directly on top of whatever
|
|
/// the user's last actual commit was.
|
|
pub staged_commit_hash: String,
|
|
/// SHA of the WIP commit that captures files that were unstaged (including
|
|
/// untracked) at the time of archiving. This commit can be empty if the user
|
|
/// had no unstaged files at the time. It sits on top of `staged_commit_hash`.
|
|
/// After doing `git reset` past both of these commits, we're back in the state
|
|
/// we had before archiving, including what was staged, what was unstaged, and
|
|
/// what was committed.
|
|
pub unstaged_commit_hash: String,
|
|
/// SHA of the commit that HEAD pointed at before we created the two WIP
|
|
/// commits during archival. After resetting past the WIP commits during
|
|
/// restore, HEAD should land back on this commit. It also serves as a
|
|
/// pre-restore sanity check (abort if this commit no longer exists in the
|
|
/// repo) and as a fallback target if the WIP resets fail.
|
|
pub original_commit_hash: String,
|
|
}
|
|
|
|
/// The store holds all metadata needed to show threads in the sidebar/the archive.
|
|
///
|
|
/// Listens to ConversationView events and updates metadata when the root thread changes.
|
|
pub struct ThreadMetadataStore {
|
|
db: ThreadMetadataDb,
|
|
threads: HashMap<ThreadId, ThreadMetadata>,
|
|
threads_by_paths: HashMap<PathList, HashSet<ThreadId>>,
|
|
threads_by_main_paths: HashMap<PathList, HashSet<ThreadId>>,
|
|
threads_by_session: HashMap<acp::SessionId, ThreadId>,
|
|
reload_task: Option<Shared<Task<()>>>,
|
|
conversation_subscriptions: HashMap<gpui::EntityId, Subscription>,
|
|
pending_thread_ops_tx: async_channel::Sender<DbOperation>,
|
|
in_flight_archives: HashMap<ThreadId, (Task<()>, async_channel::Sender<()>)>,
|
|
_db_operations_task: Task<()>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
enum DbOperation {
|
|
Upsert(ThreadMetadata),
|
|
Delete(ThreadId),
|
|
}
|
|
|
|
impl DbOperation {
|
|
fn id(&self) -> ThreadId {
|
|
match self {
|
|
DbOperation::Upsert(thread) => thread.thread_id,
|
|
DbOperation::Delete(thread_id) => *thread_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Override for the test DB name used by `ThreadMetadataStore::init_global`.
|
|
/// When set as a GPUI global, `init_global` uses this name instead of
|
|
/// deriving one from the thread name. This prevents data from leaking
|
|
/// across proptest cases that share a thread name.
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub struct TestMetadataDbName(pub String);
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl gpui::Global for TestMetadataDbName {}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl TestMetadataDbName {
|
|
pub fn global(cx: &App) -> String {
|
|
cx.try_global::<Self>()
|
|
.map(|g| g.0.clone())
|
|
.unwrap_or_else(|| {
|
|
let thread = std::thread::current();
|
|
let test_name = thread.name().unwrap_or("unknown_test");
|
|
format!("THREAD_METADATA_DB_{}", test_name)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ThreadMetadataStore {
|
|
#[cfg(not(any(test, feature = "test-support")))]
|
|
pub fn init_global(cx: &mut App) {
|
|
if cx.has_global::<Self>() {
|
|
return;
|
|
}
|
|
|
|
let db = ThreadMetadataDb::global(cx);
|
|
let thread_store = cx.new(|cx| Self::new(db, cx));
|
|
cx.set_global(GlobalThreadMetadataStore(thread_store));
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn init_global(cx: &mut App) {
|
|
let db_name = TestMetadataDbName::global(cx);
|
|
let db = gpui::block_on(db::open_test_db::<ThreadMetadataDb>(&db_name));
|
|
let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx));
|
|
cx.set_global(GlobalThreadMetadataStore(thread_store));
|
|
}
|
|
|
|
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
|
cx.try_global::<GlobalThreadMetadataStore>()
|
|
.map(|store| store.0.clone())
|
|
}
|
|
|
|
pub fn global(cx: &App) -> Entity<Self> {
|
|
cx.global::<GlobalThreadMetadataStore>().0.clone()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.threads.is_empty()
|
|
}
|
|
|
|
/// Returns all thread IDs.
|
|
pub fn entry_ids(&self) -> impl Iterator<Item = ThreadId> + '_ {
|
|
self.threads.keys().copied()
|
|
}
|
|
|
|
/// Returns the metadata for a specific thread, if it exists.
|
|
pub fn entry(&self, thread_id: ThreadId) -> Option<&ThreadMetadata> {
|
|
self.threads.get(&thread_id)
|
|
}
|
|
|
|
/// Returns the metadata for a thread identified by its ACP session ID.
|
|
pub fn entry_by_session(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> {
|
|
let thread_id = self.threads_by_session.get(session_id)?;
|
|
self.threads.get(thread_id)
|
|
}
|
|
|
|
/// Returns all threads.
|
|
pub fn entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
|
|
self.threads.values()
|
|
}
|
|
|
|
pub fn reload_task(&self) -> Shared<Task<()>> {
|
|
self.reload_task
|
|
.clone()
|
|
.unwrap_or_else(|| Task::ready(()).shared())
|
|
}
|
|
|
|
/// Returns all archived threads.
|
|
pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
|
|
self.entries().filter(|t| t.archived)
|
|
}
|
|
|
|
/// Returns all threads for the given path list and remote connection,
|
|
/// excluding archived threads.
|
|
///
|
|
/// When `remote_connection` is `Some`, only threads whose persisted
|
|
/// `remote_connection` matches by normalized identity are returned.
|
|
/// When `None`, only local (non-remote) threads are returned.
|
|
pub fn entries_for_path<'a>(
|
|
&'a self,
|
|
path_list: &PathList,
|
|
remote_connection: Option<&'a RemoteConnectionOptions>,
|
|
) -> impl Iterator<Item = &'a ThreadMetadata> + 'a {
|
|
self.threads_by_paths
|
|
.get(path_list)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|s| self.threads.get(s))
|
|
.filter(|s| !s.archived)
|
|
.filter(move |s| s.matches_remote_connection(remote_connection))
|
|
}
|
|
|
|
/// Returns threads whose `main_worktree_paths` matches the given path list
|
|
/// and remote connection, excluding archived threads. This finds threads
|
|
/// that were opened in a linked worktree but are associated with the given
|
|
/// main worktree.
|
|
///
|
|
/// When `remote_connection` is `Some`, only threads whose persisted
|
|
/// `remote_connection` matches by normalized identity are returned.
|
|
/// When `None`, only local (non-remote) threads are returned.
|
|
pub fn entries_for_main_worktree_path<'a>(
|
|
&'a self,
|
|
path_list: &PathList,
|
|
remote_connection: Option<&'a RemoteConnectionOptions>,
|
|
) -> impl Iterator<Item = &'a ThreadMetadata> + 'a {
|
|
self.threads_by_main_paths
|
|
.get(path_list)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|s| self.threads.get(s))
|
|
.filter(|s| !s.archived)
|
|
.filter(move |s| s.matches_remote_connection(remote_connection))
|
|
}
|
|
|
|
fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
|
|
let db = self.db.clone();
|
|
self.reload_task.take();
|
|
|
|
let list_task = cx
|
|
.background_spawn(async move { db.list().context("Failed to fetch sidebar metadata") });
|
|
|
|
let reload_task = cx
|
|
.spawn(async move |this, cx| {
|
|
let Some(rows) = list_task.await.log_err() else {
|
|
return;
|
|
};
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.threads.clear();
|
|
this.threads_by_paths.clear();
|
|
this.threads_by_main_paths.clear();
|
|
this.threads_by_session.clear();
|
|
|
|
for row in rows {
|
|
this.cache_thread_metadata(row);
|
|
}
|
|
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
})
|
|
.shared();
|
|
self.reload_task = Some(reload_task.clone());
|
|
reload_task
|
|
}
|
|
|
|
pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
|
|
for metadata in metadata {
|
|
self.save_internal(metadata);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
|
|
self.save_internal(metadata);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Set or clear the user-supplied title for a thread.
|
|
pub fn set_title_override(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
title_override: SharedString,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(existing) = self.entry(thread_id) else {
|
|
return;
|
|
};
|
|
if existing.title_override.as_ref() == Some(&title_override) {
|
|
return;
|
|
}
|
|
let metadata = ThreadMetadata {
|
|
title_override: Some(title_override),
|
|
..existing.clone()
|
|
};
|
|
self.save(metadata, cx);
|
|
}
|
|
|
|
fn save_internal(&mut self, metadata: ThreadMetadata) {
|
|
if let Some(thread) = self.threads.get(&metadata.thread_id) {
|
|
if thread.folder_paths() != metadata.folder_paths() {
|
|
if let Some(thread_ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
|
|
thread_ids.remove(&metadata.thread_id);
|
|
}
|
|
}
|
|
if thread.main_worktree_paths() != metadata.main_worktree_paths()
|
|
&& !thread.main_worktree_paths().is_empty()
|
|
{
|
|
if let Some(thread_ids) = self
|
|
.threads_by_main_paths
|
|
.get_mut(thread.main_worktree_paths())
|
|
{
|
|
thread_ids.remove(&metadata.thread_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.cache_thread_metadata(metadata.clone());
|
|
self.pending_thread_ops_tx
|
|
.try_send(DbOperation::Upsert(metadata))
|
|
.log_err();
|
|
}
|
|
|
|
fn cache_thread_metadata(&mut self, metadata: ThreadMetadata) {
|
|
// Drafts may not have a session_id yet; only index by session
|
|
// when one is present.
|
|
if let Some(session_id) = metadata.session_id.as_ref() {
|
|
self.threads_by_session
|
|
.insert(session_id.clone(), metadata.thread_id);
|
|
}
|
|
|
|
self.threads.insert(metadata.thread_id, metadata.clone());
|
|
|
|
self.threads_by_paths
|
|
.entry(metadata.folder_paths().clone())
|
|
.or_default()
|
|
.insert(metadata.thread_id);
|
|
|
|
if !metadata.main_worktree_paths().is_empty() {
|
|
self.threads_by_main_paths
|
|
.entry(metadata.main_worktree_paths().clone())
|
|
.or_default()
|
|
.insert(metadata.thread_id);
|
|
}
|
|
}
|
|
|
|
pub fn update_working_directories(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
work_dirs: PathList,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(thread) = self.threads.get(&thread_id) {
|
|
debug_assert!(
|
|
!thread.archived,
|
|
"update_working_directories called on archived thread"
|
|
);
|
|
self.save_internal(ThreadMetadata {
|
|
worktree_paths: WorktreePaths::from_path_lists(
|
|
thread.main_worktree_paths().clone(),
|
|
work_dirs.clone(),
|
|
)
|
|
.unwrap_or_else(|_| WorktreePaths::from_folder_paths(&work_dirs)),
|
|
..thread.clone()
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn update_worktree_paths(
|
|
&mut self,
|
|
thread_ids: &[ThreadId],
|
|
worktree_paths: WorktreePaths,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut changed = false;
|
|
for &thread_id in thread_ids {
|
|
let Some(thread) = self.threads.get(&thread_id) else {
|
|
continue;
|
|
};
|
|
if thread.worktree_paths == worktree_paths {
|
|
continue;
|
|
}
|
|
// Don't overwrite paths for archived threads — the
|
|
// project may no longer include the worktree that was
|
|
// removed during the archive flow.
|
|
if thread.archived {
|
|
continue;
|
|
}
|
|
self.save_internal(ThreadMetadata {
|
|
worktree_paths: worktree_paths.clone(),
|
|
..thread.clone()
|
|
});
|
|
changed = true;
|
|
}
|
|
if changed {
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn update_interacted_at(
|
|
&mut self,
|
|
thread_id: &ThreadId,
|
|
time: DateTime<Utc>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(thread) = self.threads.get(thread_id) {
|
|
self.save_internal(ThreadMetadata {
|
|
interacted_at: Some(time),
|
|
..thread.clone()
|
|
});
|
|
cx.notify();
|
|
};
|
|
}
|
|
|
|
pub fn archive(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
archive_job: Option<(Task<()>, async_channel::Sender<()>)>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.update_archived(thread_id, true, cx);
|
|
|
|
if let Some(job) = archive_job {
|
|
self.in_flight_archives.insert(thread_id, job);
|
|
}
|
|
|
|
cx.emit(ThreadMetadataStoreEvent::ThreadArchived(thread_id));
|
|
}
|
|
|
|
pub fn unarchive(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
|
|
self.update_archived(thread_id, false, cx);
|
|
// Dropping the Sender triggers cancellation in the background task.
|
|
self.in_flight_archives.remove(&thread_id);
|
|
}
|
|
|
|
pub fn cleanup_completed_archive(&mut self, thread_id: ThreadId) {
|
|
self.in_flight_archives.remove(&thread_id);
|
|
}
|
|
|
|
/// Returns `true` if any unarchived thread other than `thread_id`
|
|
/// references `path` in its folder paths. Used to determine whether a
|
|
/// worktree can safely be removed from disk.
|
|
pub fn path_is_referenced_by_unarchived_threads(
|
|
&self,
|
|
thread_id: Option<ThreadId>,
|
|
path: &Path,
|
|
remote_connection: Option<&RemoteConnectionOptions>,
|
|
) -> bool {
|
|
self.path_is_referenced_by_unarchived_threads_matching(
|
|
thread_id,
|
|
path,
|
|
remote_connection,
|
|
|_| true,
|
|
)
|
|
}
|
|
|
|
pub fn path_is_referenced_by_unarchived_threads_matching(
|
|
&self,
|
|
thread_id: Option<ThreadId>,
|
|
path: &Path,
|
|
remote_connection: Option<&RemoteConnectionOptions>,
|
|
matches: impl Fn(&ThreadMetadata) -> bool,
|
|
) -> bool {
|
|
self.entries().any(|thread| {
|
|
Some(thread.thread_id) != thread_id
|
|
&& !thread.archived
|
|
&& thread.matches_remote_connection(remote_connection)
|
|
&& thread.references_folder_path(path)
|
|
&& matches(thread)
|
|
})
|
|
}
|
|
|
|
/// Updates a thread's `folder_paths` after an archived worktree has been
|
|
/// restored to disk. The restored worktree may land at a different path
|
|
/// than it had before archival, so each `(old_path, new_path)` pair in
|
|
/// `path_replacements` is applied to the thread's stored folder paths.
|
|
pub fn update_restored_worktree_paths(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
path_replacements: &[(PathBuf, PathBuf)],
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(thread) = self.threads.get(&thread_id).cloned() {
|
|
let mut paths: Vec<PathBuf> = thread.folder_paths().paths().to_vec();
|
|
for (old_path, new_path) in path_replacements {
|
|
if let Some(pos) = paths.iter().position(|p| p == old_path) {
|
|
paths[pos] = new_path.clone();
|
|
}
|
|
}
|
|
let new_folder_paths = PathList::new(&paths);
|
|
self.save_internal(ThreadMetadata {
|
|
worktree_paths: WorktreePaths::from_path_lists(
|
|
thread.main_worktree_paths().clone(),
|
|
new_folder_paths.clone(),
|
|
)
|
|
.unwrap_or_else(|_| WorktreePaths::from_folder_paths(&new_folder_paths)),
|
|
..thread
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn complete_worktree_restore(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
path_replacements: &[(PathBuf, PathBuf)],
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(thread) = self.threads.get(&thread_id).cloned() {
|
|
let mut paths: Vec<PathBuf> = thread.folder_paths().paths().to_vec();
|
|
for (old_path, new_path) in path_replacements {
|
|
for path in &mut paths {
|
|
if path == old_path {
|
|
*path = new_path.clone();
|
|
}
|
|
}
|
|
}
|
|
let new_folder_paths = PathList::new(&paths);
|
|
self.save_internal(ThreadMetadata {
|
|
worktree_paths: WorktreePaths::from_path_lists(
|
|
thread.main_worktree_paths().clone(),
|
|
new_folder_paths.clone(),
|
|
)
|
|
.unwrap_or_else(|_| WorktreePaths::from_folder_paths(&new_folder_paths)),
|
|
..thread
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// Apply a mutation to the worktree paths of all threads whose current
|
|
/// `folder_paths` matches `current_folder_paths`, then re-index.
|
|
/// When `remote_connection` is provided, only threads with a matching
|
|
/// remote connection are affected.
|
|
pub fn change_worktree_paths(
|
|
&mut self,
|
|
current_folder_paths: &PathList,
|
|
remote_connection: Option<&RemoteConnectionOptions>,
|
|
mutate: impl Fn(&mut WorktreePaths),
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let thread_ids: Vec<_> = self
|
|
.threads_by_paths
|
|
.get(current_folder_paths)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|id| {
|
|
self.threads.get(id).is_some_and(|t| {
|
|
!t.archived
|
|
&& same_remote_connection_identity(
|
|
t.remote_connection.as_ref(),
|
|
remote_connection,
|
|
)
|
|
})
|
|
})
|
|
.copied()
|
|
.collect();
|
|
|
|
self.mutate_thread_paths(&thread_ids, mutate, cx);
|
|
}
|
|
|
|
fn mutate_thread_paths(
|
|
&mut self,
|
|
thread_ids: &[ThreadId],
|
|
mutate: impl Fn(&mut WorktreePaths),
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if thread_ids.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for thread_id in thread_ids {
|
|
if let Some(thread) = self.threads.get_mut(thread_id) {
|
|
if let Some(ids) = self
|
|
.threads_by_main_paths
|
|
.get_mut(thread.main_worktree_paths())
|
|
{
|
|
ids.remove(thread_id);
|
|
}
|
|
if let Some(ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
|
|
ids.remove(thread_id);
|
|
}
|
|
|
|
mutate(&mut thread.worktree_paths);
|
|
|
|
self.threads_by_main_paths
|
|
.entry(thread.main_worktree_paths().clone())
|
|
.or_default()
|
|
.insert(*thread_id);
|
|
self.threads_by_paths
|
|
.entry(thread.folder_paths().clone())
|
|
.or_default()
|
|
.insert(*thread_id);
|
|
|
|
self.pending_thread_ops_tx
|
|
.try_send(DbOperation::Upsert(thread.clone()))
|
|
.log_err();
|
|
}
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn create_archived_worktree(
|
|
&self,
|
|
worktree_path: String,
|
|
main_repo_path: String,
|
|
branch_name: Option<String>,
|
|
staged_commit_hash: String,
|
|
unstaged_commit_hash: String,
|
|
original_commit_hash: String,
|
|
cx: &App,
|
|
) -> Task<anyhow::Result<i64>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move {
|
|
db.create_archived_worktree(
|
|
worktree_path,
|
|
main_repo_path,
|
|
branch_name,
|
|
staged_commit_hash,
|
|
unstaged_commit_hash,
|
|
original_commit_hash,
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub fn link_thread_to_archived_worktree(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
archived_worktree_id: i64,
|
|
cx: &App,
|
|
) -> Task<anyhow::Result<()>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move {
|
|
db.link_thread_to_archived_worktree(thread_id, archived_worktree_id)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub fn get_archived_worktrees_for_thread(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
cx: &App,
|
|
) -> Task<anyhow::Result<Vec<ArchivedGitWorktree>>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move { db.get_archived_worktrees_for_thread(thread_id).await })
|
|
}
|
|
|
|
pub fn delete_archived_worktree(&self, id: i64, cx: &App) -> Task<anyhow::Result<()>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move { db.delete_archived_worktree(id).await })
|
|
}
|
|
|
|
pub fn unlink_thread_from_all_archived_worktrees(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
cx: &App,
|
|
) -> Task<anyhow::Result<()>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move {
|
|
db.unlink_thread_from_all_archived_worktrees(thread_id)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub fn is_archived_worktree_referenced(
|
|
&self,
|
|
archived_worktree_id: i64,
|
|
cx: &App,
|
|
) -> Task<anyhow::Result<bool>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move {
|
|
db.is_archived_worktree_referenced(archived_worktree_id)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub fn get_all_archived_branch_names(
|
|
&self,
|
|
cx: &App,
|
|
) -> Task<anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>>> {
|
|
let db = self.db.clone();
|
|
cx.background_spawn(async move { db.get_all_archived_branch_names() })
|
|
}
|
|
|
|
fn update_archived(&mut self, thread_id: ThreadId, archived: bool, cx: &mut Context<Self>) {
|
|
if let Some(thread) = self.threads.get(&thread_id) {
|
|
self.save_internal(ThreadMetadata {
|
|
archived,
|
|
..thread.clone()
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn delete(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
|
|
if let Some(thread) = self.threads.get(&thread_id) {
|
|
if let Some(sid) = &thread.session_id {
|
|
self.threads_by_session.remove(sid);
|
|
}
|
|
if let Some(thread_ids) = self.threads_by_paths.get_mut(thread.folder_paths()) {
|
|
thread_ids.remove(&thread_id);
|
|
}
|
|
if !thread.main_worktree_paths().is_empty() {
|
|
if let Some(thread_ids) = self
|
|
.threads_by_main_paths
|
|
.get_mut(thread.main_worktree_paths())
|
|
{
|
|
thread_ids.remove(&thread_id);
|
|
}
|
|
}
|
|
}
|
|
self.threads.remove(&thread_id);
|
|
self.pending_thread_ops_tx
|
|
.try_send(DbOperation::Delete(thread_id))
|
|
.log_err();
|
|
crate::draft_prompt_store::delete(thread_id, cx).detach_and_log_err(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn unarchived_draft_ids_matching(
|
|
&self,
|
|
matches: impl Fn(&ThreadMetadata) -> bool,
|
|
) -> Vec<ThreadId> {
|
|
self.entries()
|
|
.filter(|thread| thread.is_draft() && !thread.archived && matches(thread))
|
|
.map(|thread| thread.thread_id)
|
|
.collect()
|
|
}
|
|
|
|
pub fn delete_all(
|
|
&mut self,
|
|
thread_ids: impl IntoIterator<Item = ThreadId>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
for thread_id in thread_ids {
|
|
self.delete(thread_id, cx);
|
|
}
|
|
}
|
|
|
|
fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
|
|
let weak_store = cx.weak_entity();
|
|
|
|
cx.observe_new::<crate::ConversationView>(move |_view, _window, cx| {
|
|
let view_entity = cx.entity();
|
|
let entity_id = view_entity.entity_id();
|
|
|
|
cx.on_release({
|
|
let weak_store = weak_store.clone();
|
|
move |_view, cx| {
|
|
weak_store
|
|
.update(cx, |store, _cx| {
|
|
store.conversation_subscriptions.remove(&entity_id);
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
weak_store
|
|
.update(cx, |this, cx| {
|
|
let subscription = cx.subscribe(&view_entity, Self::handle_conversation_event);
|
|
this.conversation_subscriptions
|
|
.insert(entity_id, subscription);
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
|
|
let (tx, rx) = async_channel::unbounded();
|
|
let _db_operations_task = cx.background_spawn({
|
|
let db = db.clone();
|
|
async move {
|
|
while let Ok(first_update) = rx.recv().await {
|
|
let mut updates = vec![first_update];
|
|
while let Ok(update) = rx.try_recv() {
|
|
updates.push(update);
|
|
}
|
|
let updates = Self::dedup_db_operations(updates);
|
|
for operation in updates {
|
|
match operation {
|
|
DbOperation::Upsert(metadata) => {
|
|
db.save(metadata).await.log_err();
|
|
}
|
|
DbOperation::Delete(thread_id) => {
|
|
db.delete(thread_id).await.log_err();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut this = Self {
|
|
db,
|
|
threads: HashMap::default(),
|
|
threads_by_paths: HashMap::default(),
|
|
threads_by_main_paths: HashMap::default(),
|
|
threads_by_session: HashMap::default(),
|
|
reload_task: None,
|
|
conversation_subscriptions: HashMap::default(),
|
|
pending_thread_ops_tx: tx,
|
|
in_flight_archives: HashMap::default(),
|
|
_db_operations_task,
|
|
};
|
|
let _ = this.reload(cx);
|
|
this
|
|
}
|
|
|
|
fn dedup_db_operations(operations: Vec<DbOperation>) -> Vec<DbOperation> {
|
|
let mut ops = HashMap::default();
|
|
for operation in operations.into_iter().rev() {
|
|
if ops.contains_key(&operation.id()) {
|
|
continue;
|
|
}
|
|
ops.insert(operation.id(), operation);
|
|
}
|
|
ops.into_values().collect()
|
|
}
|
|
|
|
fn handle_conversation_event(
|
|
&mut self,
|
|
conversation_view: Entity<crate::ConversationView>,
|
|
_event: &crate::conversation_view::RootThreadUpdated,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let view = conversation_view.read(cx);
|
|
let thread_id = view.thread_id;
|
|
let Some(thread) = view.root_thread(cx) else {
|
|
return;
|
|
};
|
|
|
|
let thread_ref = thread.read(cx);
|
|
// Collab-hosted threads don't own their metadata locally.
|
|
if thread_ref.project().read(cx).is_via_collab() {
|
|
return;
|
|
}
|
|
let is_draft = thread_ref.is_draft_thread();
|
|
let existing_thread = self.entry(thread_id);
|
|
|
|
// Draft session IDs may change on reload, so let's not save them until they're valid
|
|
let session_id = if is_draft {
|
|
None
|
|
} else {
|
|
Some(thread_ref.session_id().clone())
|
|
};
|
|
let title = thread_ref.title();
|
|
let title_override = existing_thread.and_then(|t| t.title_override.clone());
|
|
|
|
let updated_at = Utc::now();
|
|
|
|
let created_at = existing_thread
|
|
.and_then(|t| t.created_at)
|
|
.unwrap_or_else(|| updated_at);
|
|
|
|
let interacted_at = existing_thread
|
|
.map(|t| t.interacted_at)
|
|
.unwrap_or(Some(updated_at));
|
|
|
|
let agent_id = thread_ref.connection().agent_id();
|
|
|
|
// Preserve project-dependent fields for archived threads.
|
|
// The worktree may already have been removed from the
|
|
// project as part of the archive flow, so re-evaluating
|
|
// these from the current project state would yield
|
|
// empty/incorrect results.
|
|
let (worktree_paths, remote_connection) =
|
|
if let Some(existing) = existing_thread.filter(|t| t.archived) {
|
|
(
|
|
existing.worktree_paths.clone(),
|
|
existing.remote_connection.clone(),
|
|
)
|
|
} else {
|
|
let project = thread_ref.project().read(cx);
|
|
let worktree_paths = project.worktree_paths(cx);
|
|
let remote_connection = project.remote_connection_options(cx);
|
|
|
|
(worktree_paths, remote_connection)
|
|
};
|
|
|
|
// Threads without a folder path (e.g. started in an empty
|
|
// window) are archived by default so they don't get lost,
|
|
// because they won't show up in the sidebar. Users can reload
|
|
// them from the archive.
|
|
let archived = existing_thread
|
|
.map(|t| t.archived)
|
|
.unwrap_or(worktree_paths.is_empty());
|
|
|
|
let was_draft = existing_thread.map_or(true, |t| t.is_draft());
|
|
if was_draft && !is_draft {
|
|
// Draft has been promoted: drop its persisted prompt since the
|
|
// promoted thread now owns its prompt state via the native
|
|
// agent's thread database.
|
|
crate::draft_prompt_store::delete(thread_id, cx).detach_and_log_err(cx);
|
|
}
|
|
|
|
let metadata = ThreadMetadata {
|
|
thread_id,
|
|
session_id,
|
|
agent_id,
|
|
title,
|
|
title_override,
|
|
created_at: Some(created_at),
|
|
interacted_at,
|
|
updated_at,
|
|
worktree_paths,
|
|
remote_connection,
|
|
archived,
|
|
};
|
|
|
|
self.save(metadata, cx);
|
|
}
|
|
}
|
|
|
|
impl Global for ThreadMetadataStore {}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum ThreadMetadataStoreEvent {
|
|
ThreadArchived(ThreadId),
|
|
}
|
|
|
|
impl gpui::EventEmitter<ThreadMetadataStoreEvent> for ThreadMetadataStore {}
|
|
|
|
struct ThreadMetadataDb(ThreadSafeConnection);
|
|
|
|
impl Domain for ThreadMetadataDb {
|
|
const NAME: &str = stringify!(ThreadMetadataDb);
|
|
|
|
const MIGRATIONS: &[&str] = &[
|
|
sql!(
|
|
CREATE TABLE IF NOT EXISTS sidebar_threads(
|
|
session_id TEXT PRIMARY KEY,
|
|
agent_id TEXT,
|
|
title TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
created_at TEXT,
|
|
folder_paths TEXT,
|
|
folder_paths_order TEXT
|
|
) STRICT;
|
|
),
|
|
sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
|
|
sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT),
|
|
sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT),
|
|
sql!(
|
|
CREATE TABLE IF NOT EXISTS archived_git_worktrees(
|
|
id INTEGER PRIMARY KEY,
|
|
worktree_path TEXT NOT NULL,
|
|
main_repo_path TEXT NOT NULL,
|
|
branch_name TEXT,
|
|
staged_commit_hash TEXT,
|
|
unstaged_commit_hash TEXT,
|
|
original_commit_hash TEXT
|
|
) STRICT;
|
|
|
|
CREATE TABLE IF NOT EXISTS thread_archived_worktrees(
|
|
session_id TEXT NOT NULL,
|
|
archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
|
|
PRIMARY KEY (session_id, archived_worktree_id)
|
|
) STRICT;
|
|
),
|
|
sql!(ALTER TABLE sidebar_threads ADD COLUMN remote_connection TEXT),
|
|
sql!(ALTER TABLE sidebar_threads ADD COLUMN thread_id BLOB),
|
|
sql!(
|
|
UPDATE sidebar_threads SET thread_id = randomblob(16) WHERE thread_id IS NULL;
|
|
|
|
CREATE TABLE thread_archived_worktrees_v2(
|
|
thread_id BLOB NOT NULL,
|
|
archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id),
|
|
PRIMARY KEY (thread_id, archived_worktree_id)
|
|
) STRICT;
|
|
|
|
INSERT INTO thread_archived_worktrees_v2(thread_id, archived_worktree_id)
|
|
SELECT s.thread_id, t.archived_worktree_id
|
|
FROM thread_archived_worktrees t
|
|
JOIN sidebar_threads s ON s.session_id = t.session_id;
|
|
|
|
DROP TABLE thread_archived_worktrees;
|
|
ALTER TABLE thread_archived_worktrees_v2 RENAME TO thread_archived_worktrees;
|
|
|
|
CREATE TABLE sidebar_threads_v2(
|
|
thread_id BLOB PRIMARY KEY,
|
|
session_id TEXT,
|
|
agent_id TEXT,
|
|
title TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
created_at TEXT,
|
|
folder_paths TEXT,
|
|
folder_paths_order TEXT,
|
|
archived INTEGER DEFAULT 0,
|
|
main_worktree_paths TEXT,
|
|
main_worktree_paths_order TEXT,
|
|
remote_connection TEXT
|
|
) STRICT;
|
|
|
|
INSERT INTO sidebar_threads_v2(thread_id, session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection)
|
|
SELECT thread_id, session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection
|
|
FROM sidebar_threads;
|
|
|
|
DROP TABLE sidebar_threads;
|
|
ALTER TABLE sidebar_threads_v2 RENAME TO sidebar_threads;
|
|
),
|
|
sql!(
|
|
DELETE FROM thread_archived_worktrees
|
|
WHERE thread_id IN (
|
|
SELECT thread_id FROM sidebar_threads WHERE session_id IS NULL
|
|
);
|
|
|
|
DELETE FROM sidebar_threads WHERE session_id IS NULL;
|
|
|
|
DELETE FROM archived_git_worktrees
|
|
WHERE id NOT IN (
|
|
SELECT archived_worktree_id FROM thread_archived_worktrees
|
|
);
|
|
),
|
|
sql!(
|
|
ALTER TABLE sidebar_threads ADD COLUMN interacted_at TEXT;
|
|
),
|
|
sql!(
|
|
ALTER TABLE sidebar_threads ADD COLUMN title_override TEXT;
|
|
),
|
|
];
|
|
}
|
|
|
|
db::static_connection!(ThreadMetadataDb, []);
|
|
|
|
impl ThreadMetadataDb {
|
|
#[allow(dead_code)]
|
|
pub fn list_ids(&self) -> anyhow::Result<Vec<ThreadId>> {
|
|
self.select::<ThreadId>(
|
|
"SELECT thread_id FROM sidebar_threads \
|
|
ORDER BY updated_at DESC",
|
|
)?()
|
|
}
|
|
|
|
const LIST_QUERY: &str = "SELECT thread_id, session_id, agent_id, title, updated_at, \
|
|
created_at, interacted_at, folder_paths, folder_paths_order, archived, main_worktree_paths, \
|
|
main_worktree_paths_order, remote_connection, title_override \
|
|
FROM sidebar_threads \
|
|
ORDER BY updated_at DESC";
|
|
|
|
/// List all sidebar thread metadata, ordered by updated_at descending.
|
|
///
|
|
/// Only returns threads that have a `session_id`.
|
|
pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
|
|
self.select::<ThreadMetadata>(Self::LIST_QUERY)?()
|
|
}
|
|
|
|
/// Upsert metadata for a thread.
|
|
///
|
|
/// Drafts are persisted with `session_id = None`. They get a real
|
|
/// session_id on promotion (when the first message is sent) and
|
|
/// then flow through this same upsert path.
|
|
pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
|
|
let session_id = row.session_id.as_ref().map(|s| s.0.clone());
|
|
let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
|
|
None
|
|
} else {
|
|
Some(row.agent_id.to_string())
|
|
};
|
|
let title = row
|
|
.title
|
|
.as_ref()
|
|
.map(|t| t.to_string())
|
|
.unwrap_or_default();
|
|
let updated_at = row.updated_at.to_rfc3339();
|
|
let created_at = row.created_at.map(|dt| dt.to_rfc3339());
|
|
let interacted_at = row.interacted_at.map(|dt| dt.to_rfc3339());
|
|
let serialized = row.folder_paths().serialize();
|
|
let (folder_paths, folder_paths_order) = if row.folder_paths().is_empty() {
|
|
(None, None)
|
|
} else {
|
|
(Some(serialized.paths), Some(serialized.order))
|
|
};
|
|
let main_serialized = row.main_worktree_paths().serialize();
|
|
let (main_worktree_paths, main_worktree_paths_order) =
|
|
if row.main_worktree_paths().is_empty() {
|
|
(None, None)
|
|
} else {
|
|
(Some(main_serialized.paths), Some(main_serialized.order))
|
|
};
|
|
let remote_connection = row
|
|
.remote_connection
|
|
.as_ref()
|
|
.map(serde_json::to_string)
|
|
.transpose()
|
|
.context("serialize thread metadata remote connection")?;
|
|
let title_override = row.title_override.as_ref().map(|t| t.to_string());
|
|
let thread_id = row.thread_id;
|
|
let archived = row.archived;
|
|
|
|
self.write(move |conn| {
|
|
let sql = "INSERT INTO sidebar_threads(thread_id, session_id, agent_id, title, updated_at, created_at, interacted_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection, title_override) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) \
|
|
ON CONFLICT(thread_id) DO UPDATE SET \
|
|
session_id = excluded.session_id, \
|
|
agent_id = excluded.agent_id, \
|
|
title = excluded.title, \
|
|
updated_at = excluded.updated_at, \
|
|
created_at = excluded.created_at, \
|
|
interacted_at = excluded.interacted_at, \
|
|
folder_paths = excluded.folder_paths, \
|
|
folder_paths_order = excluded.folder_paths_order, \
|
|
archived = excluded.archived, \
|
|
main_worktree_paths = excluded.main_worktree_paths, \
|
|
main_worktree_paths_order = excluded.main_worktree_paths_order, \
|
|
remote_connection = excluded.remote_connection, \
|
|
title_override = excluded.title_override";
|
|
let mut stmt = Statement::prepare(conn, sql)?;
|
|
let mut i = stmt.bind(&thread_id, 1)?;
|
|
i = stmt.bind(&session_id, i)?;
|
|
i = stmt.bind(&agent_id, i)?;
|
|
i = stmt.bind(&title, i)?;
|
|
i = stmt.bind(&updated_at, i)?;
|
|
i = stmt.bind(&created_at, i)?;
|
|
i = stmt.bind(&interacted_at, i)?;
|
|
i = stmt.bind(&folder_paths, i)?;
|
|
i = stmt.bind(&folder_paths_order, i)?;
|
|
i = stmt.bind(&archived, i)?;
|
|
i = stmt.bind(&main_worktree_paths, i)?;
|
|
i = stmt.bind(&main_worktree_paths_order, i)?;
|
|
i = stmt.bind(&remote_connection, i)?;
|
|
stmt.bind(&title_override, i)?;
|
|
stmt.exec()
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Delete metadata for a single thread.
|
|
pub async fn delete(&self, thread_id: ThreadId) -> anyhow::Result<()> {
|
|
self.write(move |conn| {
|
|
let mut stmt =
|
|
Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE thread_id = ?")?;
|
|
stmt.bind(&thread_id, 1)?;
|
|
stmt.exec()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn create_archived_worktree(
|
|
&self,
|
|
worktree_path: String,
|
|
main_repo_path: String,
|
|
branch_name: Option<String>,
|
|
staged_commit_hash: String,
|
|
unstaged_commit_hash: String,
|
|
original_commit_hash: String,
|
|
) -> anyhow::Result<i64> {
|
|
self.write(move |conn| {
|
|
let mut stmt = Statement::prepare(
|
|
conn,
|
|
"INSERT INTO archived_git_worktrees(worktree_path, main_repo_path, branch_name, staged_commit_hash, unstaged_commit_hash, original_commit_hash) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
|
|
RETURNING id",
|
|
)?;
|
|
let mut i = stmt.bind(&worktree_path, 1)?;
|
|
i = stmt.bind(&main_repo_path, i)?;
|
|
i = stmt.bind(&branch_name, i)?;
|
|
i = stmt.bind(&staged_commit_hash, i)?;
|
|
i = stmt.bind(&unstaged_commit_hash, i)?;
|
|
stmt.bind(&original_commit_hash, i)?;
|
|
stmt.maybe_row::<i64>()?.context("expected RETURNING id")
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn link_thread_to_archived_worktree(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
archived_worktree_id: i64,
|
|
) -> anyhow::Result<()> {
|
|
self.write(move |conn| {
|
|
let mut stmt = Statement::prepare(
|
|
conn,
|
|
"INSERT INTO thread_archived_worktrees(thread_id, archived_worktree_id) \
|
|
VALUES (?1, ?2)",
|
|
)?;
|
|
let i = stmt.bind(&thread_id, 1)?;
|
|
stmt.bind(&archived_worktree_id, i)?;
|
|
stmt.exec()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn get_archived_worktrees_for_thread(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
) -> anyhow::Result<Vec<ArchivedGitWorktree>> {
|
|
self.select_bound::<ThreadId, ArchivedGitWorktree>(
|
|
"SELECT a.id, a.worktree_path, a.main_repo_path, a.branch_name, a.staged_commit_hash, a.unstaged_commit_hash, a.original_commit_hash \
|
|
FROM archived_git_worktrees a \
|
|
JOIN thread_archived_worktrees t ON a.id = t.archived_worktree_id \
|
|
WHERE t.thread_id = ?1",
|
|
)?(thread_id)
|
|
}
|
|
|
|
pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> {
|
|
self.write(move |conn| {
|
|
let mut stmt = Statement::prepare(
|
|
conn,
|
|
"DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?",
|
|
)?;
|
|
stmt.bind(&id, 1)?;
|
|
stmt.exec()?;
|
|
|
|
let mut stmt =
|
|
Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?;
|
|
stmt.bind(&id, 1)?;
|
|
stmt.exec()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn unlink_thread_from_all_archived_worktrees(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
) -> anyhow::Result<()> {
|
|
self.write(move |conn| {
|
|
let mut stmt = Statement::prepare(
|
|
conn,
|
|
"DELETE FROM thread_archived_worktrees WHERE thread_id = ?",
|
|
)?;
|
|
stmt.bind(&thread_id, 1)?;
|
|
stmt.exec()
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn is_archived_worktree_referenced(
|
|
&self,
|
|
archived_worktree_id: i64,
|
|
) -> anyhow::Result<bool> {
|
|
self.select_row_bound::<i64, i64>(
|
|
"SELECT COUNT(*) FROM thread_archived_worktrees WHERE archived_worktree_id = ?1",
|
|
)?(archived_worktree_id)
|
|
.map(|count| count.unwrap_or(0) > 0)
|
|
}
|
|
|
|
pub fn get_all_archived_branch_names(
|
|
&self,
|
|
) -> anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>> {
|
|
let rows = self.select::<(ThreadId, String, String)>(
|
|
"SELECT t.thread_id, a.worktree_path, a.branch_name \
|
|
FROM thread_archived_worktrees t \
|
|
JOIN archived_git_worktrees a ON a.id = t.archived_worktree_id \
|
|
WHERE a.branch_name IS NOT NULL \
|
|
ORDER BY a.id ASC",
|
|
)?()?;
|
|
|
|
let mut result: HashMap<ThreadId, HashMap<PathBuf, String>> = HashMap::default();
|
|
for (thread_id, worktree_path, branch_name) in rows {
|
|
result
|
|
.entry(thread_id)
|
|
.or_default()
|
|
.insert(PathBuf::from(worktree_path), branch_name);
|
|
}
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
impl Column for ThreadMetadata {
|
|
fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
|
|
let (thread_id_uuid, next): (uuid::Uuid, i32) = Column::column(statement, start_index)?;
|
|
let (id, next): (Option<Arc<str>>, i32) = Column::column(statement, next)?;
|
|
let (agent_id, next): (Option<String>, i32) = Column::column(statement, next)?;
|
|
let (title, next): (String, i32) = Column::column(statement, next)?;
|
|
let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
|
|
let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
|
let (interacted_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
|
let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
|
|
let (folder_paths_order_str, next): (Option<String>, i32) =
|
|
Column::column(statement, next)?;
|
|
let (archived, next): (bool, i32) = Column::column(statement, next)?;
|
|
let (main_worktree_paths_str, next): (Option<String>, i32) =
|
|
Column::column(statement, next)?;
|
|
let (main_worktree_paths_order_str, next): (Option<String>, i32) =
|
|
Column::column(statement, next)?;
|
|
let (remote_connection_json, next): (Option<String>, i32) =
|
|
Column::column(statement, next)?;
|
|
let (title_override, next): (Option<String>, i32) = Column::column(statement, next)?;
|
|
|
|
let agent_id = agent_id
|
|
.map(|id| AgentId::new(id))
|
|
.unwrap_or(ZED_AGENT_ID.clone());
|
|
|
|
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
|
|
let created_at = created_at_str
|
|
.as_deref()
|
|
.map(DateTime::parse_from_rfc3339)
|
|
.transpose()?
|
|
.map(|dt| dt.with_timezone(&Utc));
|
|
|
|
let interacted_at = interacted_at_str
|
|
.as_deref()
|
|
.map(DateTime::parse_from_rfc3339)
|
|
.transpose()?
|
|
.map(|dt| dt.with_timezone(&Utc));
|
|
|
|
let folder_paths = folder_paths_str
|
|
.map(|paths| {
|
|
PathList::deserialize(&util::path_list::SerializedPathList {
|
|
paths,
|
|
order: folder_paths_order_str.unwrap_or_default(),
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let main_worktree_paths = main_worktree_paths_str
|
|
.map(|paths| {
|
|
PathList::deserialize(&util::path_list::SerializedPathList {
|
|
paths,
|
|
order: main_worktree_paths_order_str.unwrap_or_default(),
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let remote_connection = remote_connection_json
|
|
.as_deref()
|
|
.map(serde_json::from_str::<RemoteConnectionOptions>)
|
|
.transpose()
|
|
.context("deserialize thread metadata remote connection")?;
|
|
|
|
let worktree_paths = WorktreePaths::from_path_lists(main_worktree_paths, folder_paths)
|
|
.unwrap_or_else(|_| WorktreePaths::default());
|
|
|
|
let thread_id = ThreadId(thread_id_uuid);
|
|
|
|
Ok((
|
|
ThreadMetadata {
|
|
thread_id,
|
|
session_id: id.map(acp::SessionId::new),
|
|
agent_id,
|
|
title: if title.is_empty() || title == DEFAULT_THREAD_TITLE {
|
|
None
|
|
} else {
|
|
Some(title.into())
|
|
},
|
|
title_override: title_override
|
|
.filter(|t| !t.is_empty())
|
|
.map(SharedString::from),
|
|
updated_at,
|
|
created_at,
|
|
interacted_at,
|
|
worktree_paths,
|
|
remote_connection,
|
|
archived,
|
|
},
|
|
next,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl Column for ArchivedGitWorktree {
|
|
fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
|
|
let (id, next): (i64, i32) = Column::column(statement, start_index)?;
|
|
let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?;
|
|
let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?;
|
|
let (branch_name, next): (Option<String>, i32) = Column::column(statement, next)?;
|
|
let (staged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
|
|
let (unstaged_commit_hash, next): (String, i32) = Column::column(statement, next)?;
|
|
let (original_commit_hash, next): (String, i32) = Column::column(statement, next)?;
|
|
|
|
Ok((
|
|
ArchivedGitWorktree {
|
|
id,
|
|
worktree_path: PathBuf::from(worktree_path_str),
|
|
main_repo_path: PathBuf::from(main_repo_path_str),
|
|
branch_name,
|
|
staged_commit_hash,
|
|
unstaged_commit_hash,
|
|
original_commit_hash,
|
|
},
|
|
next,
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use acp_thread::StubAgentConnection;
|
|
use action_log::ActionLog;
|
|
use agent::DbThread;
|
|
use agent_client_protocol::schema as acp;
|
|
use gpui::{TestAppContext, VisualTestContext};
|
|
use project::FakeFs;
|
|
use project::Project;
|
|
use remote::WslConnectionOptions;
|
|
use std::path::Path;
|
|
use std::rc::Rc;
|
|
use workspace::MultiWorkspace;
|
|
|
|
fn make_db_thread(title: &str, updated_at: DateTime<Utc>) -> DbThread {
|
|
DbThread {
|
|
title: title.to_string().into(),
|
|
messages: Vec::new(),
|
|
updated_at,
|
|
detailed_summary: None,
|
|
initial_project_snapshot: None,
|
|
cumulative_token_usage: Default::default(),
|
|
request_token_usage: Default::default(),
|
|
model: None,
|
|
profile: None,
|
|
imported: false,
|
|
subagent_context: None,
|
|
speed: None,
|
|
thinking_enabled: false,
|
|
thinking_effort: None,
|
|
draft_prompt: None,
|
|
ui_scroll_position: None,
|
|
}
|
|
}
|
|
|
|
fn make_metadata(
|
|
session_id: &str,
|
|
title: &str,
|
|
updated_at: DateTime<Utc>,
|
|
folder_paths: PathList,
|
|
) -> ThreadMetadata {
|
|
ThreadMetadata {
|
|
thread_id: ThreadId::new(),
|
|
archived: false,
|
|
session_id: Some(acp::SessionId::new(session_id)),
|
|
agent_id: agent::ZED_AGENT_ID.clone(),
|
|
title: if title.is_empty() {
|
|
None
|
|
} else {
|
|
Some(title.to_string().into())
|
|
},
|
|
title_override: None,
|
|
updated_at,
|
|
created_at: Some(updated_at),
|
|
interacted_at: None,
|
|
worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
|
|
remote_connection: None,
|
|
}
|
|
}
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
cx.update(|cx| {
|
|
let settings_store = settings::SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
|
editor::init(cx);
|
|
release_channel::init("0.0.0".parse().unwrap(), cx);
|
|
prompt_store::init(cx);
|
|
<dyn Fs>::set_global(fs, cx);
|
|
ThreadMetadataStore::init_global(cx);
|
|
ThreadStore::init_global(cx);
|
|
language_model::LanguageModelRegistry::test(cx);
|
|
});
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
fn setup_panel_with_project(
|
|
project: Entity<Project>,
|
|
cx: &mut TestAppContext,
|
|
) -> (Entity<crate::AgentPanel>, VisualTestContext) {
|
|
let multi_workspace =
|
|
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
let workspace_entity = multi_workspace
|
|
.read_with(cx, |mw, _cx| mw.workspace().clone())
|
|
.unwrap();
|
|
let mut vcx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
|
let panel = workspace_entity.update_in(&mut vcx, |workspace, window, cx| {
|
|
cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx))
|
|
});
|
|
(panel, vcx)
|
|
}
|
|
|
|
fn clear_thread_metadata_remote_connection_backfill(cx: &mut TestAppContext) {
|
|
let kvp = cx.update(|cx| KeyValueStore::global(cx));
|
|
gpui::block_on(kvp.delete_kvp("thread-metadata-remote-connection-backfill".to_string()))
|
|
.unwrap();
|
|
}
|
|
|
|
fn run_store_migrations(cx: &mut TestAppContext) {
|
|
clear_thread_metadata_remote_connection_backfill(cx);
|
|
cx.update(|cx| {
|
|
let migration_task = migrate_thread_metadata(cx);
|
|
migrate_thread_remote_connections(cx, migration_task);
|
|
});
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_metadata_title_prefers_override() {
|
|
let mut metadata = make_metadata(
|
|
"session-1",
|
|
"Agent Generated Title",
|
|
Utc::now(),
|
|
PathList::default(),
|
|
);
|
|
metadata.title_override = Some("User Title".into());
|
|
|
|
assert_eq!(metadata.title().as_deref(), Some("User Title"));
|
|
assert_eq!(metadata.display_title().as_ref(), "User Title");
|
|
|
|
metadata.title_override = None;
|
|
assert_eq!(metadata.title().as_deref(), Some("Agent Generated Title"));
|
|
assert_eq!(metadata.display_title().as_ref(), "Agent Generated Title");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_database_round_trips_title_override(_cx: &mut TestAppContext) {
|
|
let now = Utc::now();
|
|
let mut metadata = make_metadata(
|
|
"session-1",
|
|
"Agent Generated Title",
|
|
now,
|
|
PathList::new(&[Path::new("/project-a")]),
|
|
);
|
|
metadata.title_override = Some("User Title".into());
|
|
|
|
let thread = std::thread::current();
|
|
let test_name = thread.name().unwrap_or("unknown_test");
|
|
let db_name = format!("THREAD_METADATA_DB_{}", test_name);
|
|
let db = ThreadMetadataDb(gpui::block_on(db::open_test_db::<ThreadMetadataDb>(
|
|
&db_name,
|
|
)));
|
|
|
|
db.save(metadata).await.unwrap();
|
|
|
|
let rows = db.list().unwrap();
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].title.as_deref(), Some("Agent Generated Title"));
|
|
assert_eq!(rows[0].title_override.as_deref(), Some("User Title"));
|
|
assert_eq!(rows[0].title().as_deref(), Some("User Title"));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_store_set_title_override_updates_cached_metadata(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let metadata = make_metadata(
|
|
"session-1",
|
|
"Agent Generated Title",
|
|
Utc::now(),
|
|
PathList::default(),
|
|
);
|
|
let thread_id = metadata.thread_id;
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(metadata, cx);
|
|
store.set_title_override(thread_id, "User Title".into(), cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
let metadata = store.entry(thread_id).expect("metadata should be cached");
|
|
assert_eq!(metadata.title.as_deref(), Some("Agent Generated Title"));
|
|
assert_eq!(metadata.title_override.as_deref(), Some("User Title"));
|
|
assert_eq!(metadata.display_title().as_ref(), "User Title");
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) {
|
|
let first_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let second_paths = PathList::new(&[Path::new("/project-b")]);
|
|
let now = Utc::now();
|
|
let older = now - chrono::Duration::seconds(1);
|
|
|
|
let thread = std::thread::current();
|
|
let test_name = thread.name().unwrap_or("unknown_test");
|
|
let db_name = format!("THREAD_METADATA_DB_{}", test_name);
|
|
let db = ThreadMetadataDb(gpui::block_on(db::open_test_db::<ThreadMetadataDb>(
|
|
&db_name,
|
|
)));
|
|
|
|
db.save(make_metadata(
|
|
"session-1",
|
|
"First Thread",
|
|
now,
|
|
first_paths.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
db.save(make_metadata(
|
|
"session-2",
|
|
"Second Thread",
|
|
older,
|
|
second_paths.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.update(|cx| {
|
|
let settings_store = settings::SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
ThreadMetadataStore::init_global(cx);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
assert_eq!(store.entry_ids().count(), 2);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-1"))
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-2"))
|
|
.is_some()
|
|
);
|
|
|
|
let first_path_entries: Vec<_> = store
|
|
.entries_for_path(&first_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(first_path_entries, vec!["session-1"]);
|
|
|
|
let second_path_entries: Vec<_> = store
|
|
.entries_for_path(&second_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(second_path_entries, vec!["session-2"]);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_store_cache_updates_after_save_and_delete(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let first_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let second_paths = PathList::new(&[Path::new("/project-b")]);
|
|
let initial_time = Utc::now();
|
|
let updated_time = initial_time + chrono::Duration::seconds(1);
|
|
|
|
let initial_metadata = make_metadata(
|
|
"session-1",
|
|
"First Thread",
|
|
initial_time,
|
|
first_paths.clone(),
|
|
);
|
|
let session1_thread_id = initial_metadata.thread_id;
|
|
|
|
let second_metadata = make_metadata(
|
|
"session-2",
|
|
"Second Thread",
|
|
initial_time,
|
|
second_paths.clone(),
|
|
);
|
|
let session2_thread_id = second_metadata.thread_id;
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(initial_metadata, cx);
|
|
store.save(second_metadata, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let first_path_entries: Vec<_> = store
|
|
.entries_for_path(&first_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(first_path_entries, vec!["session-1"]);
|
|
|
|
let second_path_entries: Vec<_> = store
|
|
.entries_for_path(&second_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(second_path_entries, vec!["session-2"]);
|
|
});
|
|
|
|
let moved_metadata = ThreadMetadata {
|
|
thread_id: session1_thread_id,
|
|
session_id: Some(acp::SessionId::new("session-1")),
|
|
agent_id: agent::ZED_AGENT_ID.clone(),
|
|
title: Some("First Thread".into()),
|
|
title_override: None,
|
|
updated_at: updated_time,
|
|
created_at: Some(updated_time),
|
|
interacted_at: None,
|
|
worktree_paths: WorktreePaths::from_folder_paths(&second_paths),
|
|
remote_connection: None,
|
|
archived: false,
|
|
};
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(moved_metadata, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
assert_eq!(store.entry_ids().count(), 2);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-1"))
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-2"))
|
|
.is_some()
|
|
);
|
|
|
|
let first_path_entries: Vec<_> = store
|
|
.entries_for_path(&first_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert!(first_path_entries.is_empty());
|
|
|
|
let second_path_entries: Vec<_> = store
|
|
.entries_for_path(&second_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(second_path_entries.len(), 2);
|
|
assert!(second_path_entries.contains(&"session-1".to_string()));
|
|
assert!(second_path_entries.contains(&"session-2".to_string()));
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.delete(session2_thread_id, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
assert_eq!(store.entry_ids().count(), 1);
|
|
|
|
let second_path_entries: Vec<_> = store
|
|
.entries_for_path(&second_paths, None)
|
|
.filter_map(|entry| entry.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(second_path_entries, vec!["session-1"]);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let project_a_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let project_b_paths = PathList::new(&[Path::new("/project-b")]);
|
|
let now = Utc::now();
|
|
|
|
let existing_metadata = ThreadMetadata {
|
|
thread_id: ThreadId::new(),
|
|
session_id: Some(acp::SessionId::new("a-session-0")),
|
|
agent_id: agent::ZED_AGENT_ID.clone(),
|
|
title: Some("Existing Metadata".into()),
|
|
title_override: None,
|
|
updated_at: now - chrono::Duration::seconds(10),
|
|
created_at: Some(now - chrono::Duration::seconds(10)),
|
|
interacted_at: None,
|
|
worktree_paths: WorktreePaths::from_folder_paths(&project_a_paths),
|
|
remote_connection: None,
|
|
archived: false,
|
|
};
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(existing_metadata, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let threads_to_save = vec![
|
|
(
|
|
"a-session-0",
|
|
"Thread A0 From Native Store",
|
|
project_a_paths.clone(),
|
|
now,
|
|
),
|
|
(
|
|
"a-session-1",
|
|
"Thread A1",
|
|
project_a_paths.clone(),
|
|
now + chrono::Duration::seconds(1),
|
|
),
|
|
(
|
|
"b-session-0",
|
|
"Thread B0",
|
|
project_b_paths.clone(),
|
|
now + chrono::Duration::seconds(2),
|
|
),
|
|
(
|
|
"projectless",
|
|
"Projectless",
|
|
PathList::default(),
|
|
now + chrono::Duration::seconds(3),
|
|
),
|
|
];
|
|
|
|
for (session_id, title, paths, updated_at) in &threads_to_save {
|
|
let save_task = cx.update(|cx| {
|
|
let thread_store = ThreadStore::global(cx);
|
|
let session_id = session_id.to_string();
|
|
let title = title.to_string();
|
|
let paths = paths.clone();
|
|
thread_store.update(cx, |store, cx| {
|
|
store.save_thread(
|
|
acp::SessionId::new(session_id),
|
|
make_db_thread(&title, *updated_at),
|
|
paths,
|
|
cx,
|
|
)
|
|
})
|
|
});
|
|
save_task.await.unwrap();
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
run_store_migrations(cx);
|
|
|
|
let list = cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.read(cx).entries().cloned().collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(list.len(), 4);
|
|
assert!(
|
|
list.iter()
|
|
.all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
|
|
);
|
|
|
|
let existing_metadata = list
|
|
.iter()
|
|
.find(|metadata| {
|
|
metadata
|
|
.session_id
|
|
.as_ref()
|
|
.is_some_and(|s| s.0.as_ref() == "a-session-0")
|
|
})
|
|
.unwrap();
|
|
assert_eq!(existing_metadata.display_title(), "Existing Metadata");
|
|
assert!(!existing_metadata.archived);
|
|
|
|
let migrated_session_ids: Vec<_> = list
|
|
.iter()
|
|
.filter_map(|metadata| metadata.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert!(migrated_session_ids.iter().any(|s| s == "a-session-1"));
|
|
assert!(migrated_session_ids.iter().any(|s| s == "b-session-0"));
|
|
assert!(migrated_session_ids.iter().any(|s| s == "projectless"));
|
|
|
|
// The per-batch top-5 rescue applies: each migrated thread that has
|
|
// a project becomes the most-recent-in-its-project within this batch
|
|
// and is unarchived. Only the projectless thread stays archived,
|
|
// because the rescue only applies to threads with a folder path.
|
|
let migrated_by_session: HashMap<String, &ThreadMetadata> = list
|
|
.iter()
|
|
.filter_map(|metadata| {
|
|
let session_id = metadata.session_id.as_ref()?.0.to_string();
|
|
(session_id != "a-session-0").then_some((session_id, metadata))
|
|
})
|
|
.collect();
|
|
assert!(!migrated_by_session["a-session-1"].archived);
|
|
assert!(!migrated_by_session["b-session-0"].archived);
|
|
assert!(migrated_by_session["projectless"].archived);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let project_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let existing_updated_at = Utc::now();
|
|
|
|
let existing_metadata = ThreadMetadata {
|
|
thread_id: ThreadId::new(),
|
|
session_id: Some(acp::SessionId::new("existing-session")),
|
|
agent_id: agent::ZED_AGENT_ID.clone(),
|
|
title: Some("Existing Metadata".into()),
|
|
title_override: None,
|
|
updated_at: existing_updated_at,
|
|
created_at: Some(existing_updated_at),
|
|
interacted_at: None,
|
|
worktree_paths: WorktreePaths::from_folder_paths(&project_paths),
|
|
remote_connection: None,
|
|
archived: false,
|
|
};
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(existing_metadata, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let save_task = cx.update(|cx| {
|
|
let thread_store = ThreadStore::global(cx);
|
|
thread_store.update(cx, |store, cx| {
|
|
store.save_thread(
|
|
acp::SessionId::new("existing-session"),
|
|
make_db_thread(
|
|
"Updated Native Thread Title",
|
|
existing_updated_at + chrono::Duration::seconds(1),
|
|
),
|
|
project_paths.clone(),
|
|
cx,
|
|
)
|
|
})
|
|
});
|
|
save_task.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
run_store_migrations(cx);
|
|
|
|
let list = cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.read(cx).entries().cloned().collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(
|
|
list[0].session_id.as_ref().unwrap().0.as_ref(),
|
|
"existing-session"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_migrate_thread_remote_connections_backfills_from_workspace_db(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let folder_paths = PathList::new(&[Path::new("/remote-project")]);
|
|
let updated_at = Utc::now();
|
|
let metadata = make_metadata(
|
|
"remote-session",
|
|
"Remote Thread",
|
|
updated_at,
|
|
folder_paths.clone(),
|
|
);
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(metadata, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let workspace_db = cx.update(|cx| WorkspaceDb::global(cx));
|
|
let workspace_id = workspace_db.next_id().await.unwrap();
|
|
let serialized_paths = folder_paths.serialize();
|
|
let remote_connection_id = 1_i64;
|
|
workspace_db
|
|
.write(move |conn| {
|
|
let mut stmt = Statement::prepare(
|
|
conn,
|
|
"INSERT INTO remote_connections(id, kind, user, distro) VALUES (?1, ?2, ?3, ?4)",
|
|
)?;
|
|
let mut next_index = stmt.bind(&remote_connection_id, 1)?;
|
|
next_index = stmt.bind(&"wsl", next_index)?;
|
|
next_index = stmt.bind(&Some("anth".to_string()), next_index)?;
|
|
stmt.bind(&Some("Ubuntu".to_string()), next_index)?;
|
|
stmt.exec()?;
|
|
|
|
let mut stmt = Statement::prepare(
|
|
conn,
|
|
"UPDATE workspaces SET paths = ?2, paths_order = ?3, remote_connection_id = ?4, timestamp = CURRENT_TIMESTAMP WHERE workspace_id = ?1",
|
|
)?;
|
|
let mut next_index = stmt.bind(&workspace_id, 1)?;
|
|
next_index = stmt.bind(&serialized_paths.paths, next_index)?;
|
|
next_index = stmt.bind(&serialized_paths.order, next_index)?;
|
|
stmt.bind(&Some(remote_connection_id as i32), next_index)?;
|
|
stmt.exec()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
clear_thread_metadata_remote_connection_backfill(cx);
|
|
cx.update(|cx| {
|
|
migrate_thread_remote_connections(cx, Task::ready(Ok(())));
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let metadata = cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store
|
|
.read(cx)
|
|
.entry_by_session(&acp::SessionId::new("remote-session"))
|
|
.cloned()
|
|
.expect("expected migrated metadata row")
|
|
});
|
|
|
|
assert_eq!(
|
|
metadata.remote_connection,
|
|
Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
|
|
distro_name: "Ubuntu".to_string(),
|
|
user: Some("anth".to_string()),
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_migrate_thread_metadata_archives_beyond_five_most_recent_per_project(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let project_a_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let project_b_paths = PathList::new(&[Path::new("/project-b")]);
|
|
let now = Utc::now();
|
|
|
|
// Create 7 threads for project A and 3 for project B
|
|
let mut threads_to_save = Vec::new();
|
|
for i in 0..7 {
|
|
threads_to_save.push((
|
|
format!("a-session-{i}"),
|
|
format!("Thread A{i}"),
|
|
project_a_paths.clone(),
|
|
now + chrono::Duration::seconds(i as i64),
|
|
));
|
|
}
|
|
for i in 0..3 {
|
|
threads_to_save.push((
|
|
format!("b-session-{i}"),
|
|
format!("Thread B{i}"),
|
|
project_b_paths.clone(),
|
|
now + chrono::Duration::seconds(i as i64),
|
|
));
|
|
}
|
|
|
|
for (session_id, title, paths, updated_at) in &threads_to_save {
|
|
let save_task = cx.update(|cx| {
|
|
let thread_store = ThreadStore::global(cx);
|
|
let session_id = session_id.to_string();
|
|
let title = title.to_string();
|
|
let paths = paths.clone();
|
|
thread_store.update(cx, |store, cx| {
|
|
store.save_thread(
|
|
acp::SessionId::new(session_id),
|
|
make_db_thread(&title, *updated_at),
|
|
paths,
|
|
cx,
|
|
)
|
|
})
|
|
});
|
|
save_task.await.unwrap();
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
run_store_migrations(cx);
|
|
|
|
let list = cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.read(cx).entries().cloned().collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(list.len(), 10);
|
|
|
|
// Project A: 5 most recent should be unarchived, 2 oldest should be archived
|
|
let mut project_a_entries: Vec<_> = list
|
|
.iter()
|
|
.filter(|m| *m.folder_paths() == project_a_paths)
|
|
.collect();
|
|
assert_eq!(project_a_entries.len(), 7);
|
|
project_a_entries.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
|
|
|
|
for entry in &project_a_entries[..5] {
|
|
assert!(
|
|
!entry.archived,
|
|
"Expected {:?} to be unarchived (top 5 most recent)",
|
|
entry.session_id
|
|
);
|
|
}
|
|
for entry in &project_a_entries[5..] {
|
|
assert!(
|
|
entry.archived,
|
|
"Expected {:?} to be archived (older than top 5)",
|
|
entry.session_id
|
|
);
|
|
}
|
|
|
|
// Project B: all 3 should be unarchived (under the limit)
|
|
let project_b_entries: Vec<_> = list
|
|
.iter()
|
|
.filter(|m| *m.folder_paths() == project_b_paths)
|
|
.collect();
|
|
assert_eq!(project_b_entries.len(), 3);
|
|
assert!(project_b_entries.iter().all(|m| !m.archived));
|
|
}
|
|
|
|
// Regression test for the race between `ThreadStore::reload` and
|
|
// `migrate_thread_metadata`. `ThreadStore::new` constructs with an empty
|
|
// in-memory cache and kicks off `reload()` as a fire-and-forget task. If
|
|
// `migrate_thread_metadata` reads `ThreadStore::entries()` before that
|
|
// reload completes, it observes an empty iterator and no-ops, even though
|
|
// the on-disk legacy DB has threads to migrate. In production this
|
|
// manifests as "my old threads disappeared after upgrading": the threads
|
|
// are still in the legacy `threads.db`, but never make it into
|
|
// `sidebar_threads`, so the new sidebar UI can't see them.
|
|
#[gpui::test]
|
|
async fn test_migration_awaits_thread_store_reload(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
// Seed the legacy threads DB via the ThreadStore (the only public
|
|
// save path in this crate), then park to make sure the rows are on
|
|
// disk and `ThreadStore`'s in-memory cache is populated.
|
|
let project_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let now = Utc::now();
|
|
for i in 0..3 {
|
|
let save_task = cx.update(|cx| {
|
|
let thread_store = ThreadStore::global(cx);
|
|
let session_id = format!("legacy-session-{i}");
|
|
let title = format!("Legacy Thread {i}");
|
|
let updated_at = now + chrono::Duration::seconds(i as i64);
|
|
let paths = project_paths.clone();
|
|
thread_store.update(cx, |store, cx| {
|
|
store.save_thread(
|
|
acp::SessionId::new(session_id),
|
|
make_db_thread(&title, updated_at),
|
|
paths,
|
|
cx,
|
|
)
|
|
})
|
|
});
|
|
save_task.await.unwrap();
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
// Re-initialize `ThreadStore` so its in-memory cache is freshly empty
|
|
// and a new async `reload` task is kicked off. This reproduces the
|
|
// cold-boot state where the migration runs before the store has
|
|
// populated itself from disk. The on-disk legacy DB still has the
|
|
// three threads we saved above.
|
|
cx.update(|cx| ThreadStore::init_global(cx));
|
|
|
|
// Crucially: do NOT run_until_parked here. If we parked, the reload
|
|
// would complete, ThreadStore::entries() would return the 3 rows, and
|
|
// the race would be hidden. We want the migration to run with
|
|
// `ThreadStore::entries()` still returning an empty iterator.
|
|
run_store_migrations(cx);
|
|
|
|
let list = cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.read(cx).entries().cloned().collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(
|
|
list.len(),
|
|
3,
|
|
"Expected migration to pick up all 3 legacy threads even when \
|
|
ThreadStore::reload has not yet completed, but got {} entries",
|
|
list.len()
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_draft_thread_metadata_promotes_on_first_message(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let project = Project::test(fs, None::<&Path>, cx).await;
|
|
let connection = StubAgentConnection::new();
|
|
|
|
let (panel, mut vcx) = setup_panel_with_project(project, cx);
|
|
crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
|
|
|
|
let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
let session_id = thread.read_with(&vcx, |t, _| t.session_id().clone());
|
|
let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
|
|
|
|
// Empty (draft) threads are persisted with `session_id: None`.
|
|
cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
assert_eq!(store.entry_ids().count(), 1);
|
|
let entry = store.entry(thread_id).expect("draft metadata row");
|
|
assert!(
|
|
entry.is_draft(),
|
|
"expected draft row to have session_id=None, got {:?}",
|
|
entry.session_id
|
|
);
|
|
});
|
|
|
|
// Updating the title while still a draft keeps the row as a draft.
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.set_title("Draft Thread".into(), cx).detach();
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
let entry = store.entry(thread_id).expect("draft metadata row");
|
|
assert!(entry.is_draft(), "still a draft after title update");
|
|
assert_eq!(
|
|
entry.title.as_ref().map(|t| t.as_ref()),
|
|
Some("Draft Thread")
|
|
);
|
|
});
|
|
|
|
// Pushing content promotes the draft: session_id is now populated.
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "Hello".into(), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
assert_eq!(store.entry_ids().count(), 1);
|
|
assert_eq!(
|
|
store.entry(thread_id).unwrap().session_id.as_ref(),
|
|
Some(&session_id),
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let project = Project::test(fs, None::<&Path>, cx).await;
|
|
let connection = StubAgentConnection::new();
|
|
|
|
let (panel, mut vcx) = setup_panel_with_project(project, cx);
|
|
crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
|
|
|
|
let session_id = crate::test_support::active_session_id(&panel, &vcx);
|
|
let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "Hello".into(), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
assert_eq!(store.entry_ids().count(), 1);
|
|
assert!(store.entry_by_session(&session_id).is_some());
|
|
});
|
|
|
|
// Dropping the panel releases the ConversationView and its thread.
|
|
drop(panel);
|
|
cx.update(|_| {});
|
|
cx.run_until_parked();
|
|
|
|
cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
assert_eq!(store.entry_ids().count(), 1);
|
|
assert!(store.entry_by_session(&session_id).is_some());
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_threads_without_project_association_are_archived_by_default(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
|
|
let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
|
|
|
|
// Thread in project without worktree
|
|
let (panel_no_wt, mut vcx_no_wt) = setup_panel_with_project(project_without_worktree, cx);
|
|
crate::test_support::open_thread_with_connection(
|
|
&panel_no_wt,
|
|
StubAgentConnection::new(),
|
|
&mut vcx_no_wt,
|
|
);
|
|
let thread_no_wt = panel_no_wt.read_with(&vcx_no_wt, |panel, cx| {
|
|
panel.active_agent_thread(cx).unwrap()
|
|
});
|
|
thread_no_wt.update_in(&mut vcx_no_wt, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "content".into(), cx);
|
|
thread.set_title("No Project Thread".into(), cx).detach();
|
|
});
|
|
vcx_no_wt.run_until_parked();
|
|
let session_without_worktree =
|
|
crate::test_support::active_session_id(&panel_no_wt, &vcx_no_wt);
|
|
|
|
// Thread in project with worktree
|
|
let (panel_wt, mut vcx_wt) = setup_panel_with_project(project_with_worktree, cx);
|
|
crate::test_support::open_thread_with_connection(
|
|
&panel_wt,
|
|
StubAgentConnection::new(),
|
|
&mut vcx_wt,
|
|
);
|
|
let thread_wt =
|
|
panel_wt.read_with(&vcx_wt, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
thread_wt.update_in(&mut vcx_wt, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "content".into(), cx);
|
|
thread.set_title("Project Thread".into(), cx).detach();
|
|
});
|
|
vcx_wt.run_until_parked();
|
|
let session_with_worktree = crate::test_support::active_session_id(&panel_wt, &vcx_wt);
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let without_worktree = store
|
|
.entry_by_session(&session_without_worktree)
|
|
.expect("missing metadata for thread without project association");
|
|
assert!(without_worktree.folder_paths().is_empty());
|
|
assert!(
|
|
without_worktree.archived,
|
|
"expected thread without project association to be archived"
|
|
);
|
|
|
|
let with_worktree = store
|
|
.entry_by_session(&session_with_worktree)
|
|
.expect("missing metadata for thread with project association");
|
|
assert_eq!(
|
|
*with_worktree.folder_paths(),
|
|
PathList::new(&[Path::new("/project-a")])
|
|
);
|
|
assert!(
|
|
!with_worktree.archived,
|
|
"expected thread with project association to remain unarchived"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let project = Project::test(fs, None::<&Path>, cx).await;
|
|
let connection = Rc::new(StubAgentConnection::new());
|
|
|
|
// Create a regular (non-subagent) thread through the panel.
|
|
let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
|
|
crate::test_support::open_thread_with_connection(&panel, (*connection).clone(), &mut vcx);
|
|
|
|
let regular_thread =
|
|
panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
let regular_session_id = regular_thread.read_with(&vcx, |t, _| t.session_id().clone());
|
|
|
|
regular_thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "content".into(), cx);
|
|
thread.set_title("Regular Thread".into(), cx).detach();
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
// Create a standalone subagent AcpThread (not wrapped in a
|
|
// ConversationView). The ThreadMetadataStore only observes
|
|
// ConversationView events, so this thread's events should
|
|
// have no effect on sidebar metadata.
|
|
let subagent_session_id = acp::SessionId::new("subagent-session");
|
|
let subagent_thread = cx.update(|cx| {
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
cx.new(|cx| {
|
|
acp_thread::AcpThread::new(
|
|
Some(regular_session_id.clone()),
|
|
Some("Subagent Thread".into()),
|
|
None,
|
|
connection.clone(),
|
|
project.clone(),
|
|
action_log,
|
|
subagent_session_id.clone(),
|
|
watch::Receiver::constant(acp::PromptCapabilities::new()),
|
|
cx,
|
|
)
|
|
})
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
subagent_thread.update(cx, |thread, cx| {
|
|
thread
|
|
.set_title("Subagent Thread Title".into(), cx)
|
|
.detach();
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// Only the regular thread should appear in sidebar metadata.
|
|
// The subagent thread is excluded because the metadata store
|
|
// only observes ConversationView events.
|
|
let list = cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.read(cx).entries().cloned().collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(
|
|
list.len(),
|
|
1,
|
|
"Expected only the regular thread in sidebar metadata, \
|
|
but found {} entries (subagent threads are leaking into the sidebar)",
|
|
list.len(),
|
|
);
|
|
assert_eq!(list[0].session_id.as_ref().unwrap(), ®ular_session_id);
|
|
assert_eq!(list[0].display_title(), "Regular Thread");
|
|
}
|
|
|
|
#[test]
|
|
fn test_dedup_db_operations_keeps_latest_operation_for_session() {
|
|
let now = Utc::now();
|
|
|
|
let meta = make_metadata("session-1", "First Thread", now, PathList::default());
|
|
let thread_id = meta.thread_id;
|
|
let operations = vec![DbOperation::Upsert(meta), DbOperation::Delete(thread_id)];
|
|
|
|
let deduped = ThreadMetadataStore::dedup_db_operations(operations);
|
|
|
|
assert_eq!(deduped.len(), 1);
|
|
assert_eq!(deduped[0], DbOperation::Delete(thread_id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_dedup_db_operations_keeps_latest_insert_for_same_session() {
|
|
let now = Utc::now();
|
|
let later = now + chrono::Duration::seconds(1);
|
|
|
|
let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
|
|
let shared_thread_id = old_metadata.thread_id;
|
|
let new_metadata = ThreadMetadata {
|
|
thread_id: shared_thread_id,
|
|
..make_metadata("session-1", "New Title", later, PathList::default())
|
|
};
|
|
|
|
let deduped = ThreadMetadataStore::dedup_db_operations(vec![
|
|
DbOperation::Upsert(old_metadata),
|
|
DbOperation::Upsert(new_metadata.clone()),
|
|
]);
|
|
|
|
assert_eq!(deduped.len(), 1);
|
|
assert_eq!(deduped[0], DbOperation::Upsert(new_metadata));
|
|
}
|
|
|
|
#[test]
|
|
fn test_dedup_db_operations_preserves_distinct_sessions() {
|
|
let now = Utc::now();
|
|
|
|
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::Upsert(metadata1.clone()),
|
|
DbOperation::Upsert(metadata2.clone()),
|
|
]);
|
|
|
|
assert_eq!(deduped.len(), 2);
|
|
assert!(deduped.contains(&DbOperation::Upsert(metadata1)));
|
|
assert!(deduped.contains(&DbOperation::Upsert(metadata2)));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_archive_and_unarchive_thread(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.clone());
|
|
let thread_id = metadata.thread_id;
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(metadata, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let path_entries: Vec<_> = store
|
|
.entries_for_path(&paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(path_entries, vec!["session-1"]);
|
|
|
|
assert_eq!(store.archived_entries().count(), 0);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.archive(thread_id, None, cx);
|
|
});
|
|
});
|
|
|
|
// Thread 1 should now be archived
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let path_entries: Vec<_> = store
|
|
.entries_for_path(&paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert!(path_entries.is_empty());
|
|
|
|
let archived: Vec<_> = store.archived_entries().collect();
|
|
assert_eq!(archived.len(), 1);
|
|
assert_eq!(
|
|
archived[0].session_id.as_ref().unwrap().0.as_ref(),
|
|
"session-1"
|
|
);
|
|
assert!(archived[0].archived);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.unarchive(thread_id, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let path_entries: Vec<_> = store
|
|
.entries_for_path(&paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(path_entries, vec!["session-1"]);
|
|
|
|
assert_eq!(store.archived_entries().count(), 0);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let paths = PathList::new(&[Path::new("/project-a")]);
|
|
let now = Utc::now();
|
|
|
|
let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
|
|
let metadata2 = make_metadata(
|
|
"session-2",
|
|
"Archived Thread",
|
|
now - chrono::Duration::seconds(1),
|
|
paths.clone(),
|
|
);
|
|
let session2_thread_id = metadata2.thread_id;
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(metadata1, cx);
|
|
store.save(metadata2, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.archive(session2_thread_id, None, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let path_entries: Vec<_> = store
|
|
.entries_for_path(&paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(path_entries, vec!["session-1"]);
|
|
|
|
assert_eq!(store.entries().count(), 2);
|
|
|
|
let archived: Vec<_> = store
|
|
.archived_entries()
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(archived, vec!["session-2"]);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_entries_filter_by_remote_connection(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let main_paths = PathList::new(&[Path::new("/project-a")]);
|
|
let linked_paths = PathList::new(&[Path::new("/wt-feature")]);
|
|
let now = Utc::now();
|
|
|
|
let remote_a = RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 1 });
|
|
let remote_b = RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 2 });
|
|
|
|
// Three threads at the same folder_paths but different hosts.
|
|
let local_thread = make_metadata("local-session", "Local Thread", now, main_paths.clone());
|
|
|
|
let mut remote_a_thread = make_metadata(
|
|
"remote-a-session",
|
|
"Remote A Thread",
|
|
now - chrono::Duration::seconds(1),
|
|
main_paths.clone(),
|
|
);
|
|
remote_a_thread.remote_connection = Some(remote_a.clone());
|
|
|
|
let mut remote_b_thread = make_metadata(
|
|
"remote-b-session",
|
|
"Remote B Thread",
|
|
now - chrono::Duration::seconds(2),
|
|
main_paths.clone(),
|
|
);
|
|
remote_b_thread.remote_connection = Some(remote_b.clone());
|
|
|
|
let linked_worktree_paths =
|
|
WorktreePaths::from_path_lists(main_paths.clone(), linked_paths).unwrap();
|
|
|
|
let local_linked_thread = ThreadMetadata {
|
|
thread_id: ThreadId::new(),
|
|
archived: false,
|
|
session_id: Some(acp::SessionId::new("local-linked")),
|
|
agent_id: agent::ZED_AGENT_ID.clone(),
|
|
title: Some("Local Linked".into()),
|
|
title_override: None,
|
|
updated_at: now,
|
|
created_at: Some(now),
|
|
interacted_at: None,
|
|
worktree_paths: linked_worktree_paths.clone(),
|
|
remote_connection: None,
|
|
};
|
|
|
|
let remote_linked_thread = ThreadMetadata {
|
|
thread_id: ThreadId::new(),
|
|
archived: false,
|
|
session_id: Some(acp::SessionId::new("remote-linked")),
|
|
agent_id: agent::ZED_AGENT_ID.clone(),
|
|
title: Some("Remote Linked".into()),
|
|
title_override: None,
|
|
updated_at: now - chrono::Duration::seconds(1),
|
|
created_at: Some(now - chrono::Duration::seconds(1)),
|
|
interacted_at: None,
|
|
worktree_paths: linked_worktree_paths,
|
|
remote_connection: Some(remote_a.clone()),
|
|
};
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(local_thread, cx);
|
|
store.save(remote_a_thread, cx);
|
|
store.save(remote_b_thread, cx);
|
|
store.save(local_linked_thread, cx);
|
|
store.save(remote_linked_thread, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let local_entries: Vec<_> = store
|
|
.entries_for_path(&main_paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(local_entries, vec!["local-session"]);
|
|
|
|
let remote_a_entries: Vec<_> = store
|
|
.entries_for_path(&main_paths, Some(&remote_a))
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(remote_a_entries, vec!["remote-a-session"]);
|
|
|
|
let remote_b_entries: Vec<_> = store
|
|
.entries_for_path(&main_paths, Some(&remote_b))
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(remote_b_entries, vec!["remote-b-session"]);
|
|
|
|
let mut local_main_entries: Vec<_> = store
|
|
.entries_for_main_worktree_path(&main_paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
local_main_entries.sort();
|
|
assert_eq!(local_main_entries, vec!["local-linked", "local-session"]);
|
|
|
|
let mut remote_main_entries: Vec<_> = store
|
|
.entries_for_main_worktree_path(&main_paths, Some(&remote_a))
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
remote_main_entries.sort();
|
|
assert_eq!(
|
|
remote_main_entries,
|
|
vec!["remote-a-session", "remote-linked"]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let paths = PathList::new(&[Path::new("/project-a")]);
|
|
let now = Utc::now();
|
|
|
|
let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
|
|
let m2 = make_metadata(
|
|
"session-2",
|
|
"Thread Two",
|
|
now - chrono::Duration::seconds(1),
|
|
paths.clone(),
|
|
);
|
|
let m3 = make_metadata(
|
|
"session-3",
|
|
"Thread Three",
|
|
now - chrono::Duration::seconds(2),
|
|
paths,
|
|
);
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save_all(vec![m1, m2, m3], cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
assert_eq!(store.entries().count(), 3);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-1"))
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-2"))
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
store
|
|
.entry_by_session(&acp::SessionId::new("session-3"))
|
|
.is_some()
|
|
);
|
|
|
|
assert_eq!(store.entry_ids().count(), 3);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_archived_flag_persists_across_reload(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.clone());
|
|
let thread_id = metadata.thread_id;
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(metadata, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.archive(thread_id, None, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
let _ = store.reload(cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
let thread = store
|
|
.entry_by_session(&acp::SessionId::new("session-1"))
|
|
.expect("thread should exist after reload");
|
|
assert!(thread.archived);
|
|
|
|
let path_entries: Vec<_> = store
|
|
.entries_for_path(&paths, None)
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert!(path_entries.is_empty());
|
|
|
|
let archived: Vec<_> = store
|
|
.archived_entries()
|
|
.filter_map(|e| e.session_id.as_ref().map(|s| s.0.to_string()))
|
|
.collect();
|
|
assert_eq!(archived, vec!["session-1"]);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.archive(ThreadId::new(), None, cx);
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let store = store.read(cx);
|
|
|
|
assert!(store.is_empty());
|
|
assert_eq!(store.entries().count(), 0);
|
|
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 thread_id = metadata.thread_id;
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.save(metadata.clone(), cx);
|
|
store.archive(thread_id, None, 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
|
|
}]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_create_and_retrieve_archived_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let id = store
|
|
.read_with(cx, |store, cx| {
|
|
store.create_archived_worktree(
|
|
"/tmp/worktree".to_string(),
|
|
"/home/user/repo".to_string(),
|
|
Some("feature-branch".to_string()),
|
|
"staged_aaa".to_string(),
|
|
"unstaged_bbb".to_string(),
|
|
"original_000".to_string(),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let thread_id_1 = ThreadId::new();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| {
|
|
store.link_thread_to_archived_worktree(thread_id_1, id, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let worktrees = store
|
|
.read_with(cx, |store, cx| {
|
|
store.get_archived_worktrees_for_thread(thread_id_1, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(worktrees.len(), 1);
|
|
let wt = &worktrees[0];
|
|
assert_eq!(wt.id, id);
|
|
assert_eq!(wt.worktree_path, PathBuf::from("/tmp/worktree"));
|
|
assert_eq!(wt.main_repo_path, PathBuf::from("/home/user/repo"));
|
|
assert_eq!(wt.branch_name.as_deref(), Some("feature-branch"));
|
|
assert_eq!(wt.staged_commit_hash, "staged_aaa");
|
|
assert_eq!(wt.unstaged_commit_hash, "unstaged_bbb");
|
|
assert_eq!(wt.original_commit_hash, "original_000");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_delete_archived_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let id = store
|
|
.read_with(cx, |store, cx| {
|
|
store.create_archived_worktree(
|
|
"/tmp/worktree".to_string(),
|
|
"/home/user/repo".to_string(),
|
|
Some("main".to_string()),
|
|
"deadbeef".to_string(),
|
|
"deadbeef".to_string(),
|
|
"original_000".to_string(),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let thread_id_1 = ThreadId::new();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| {
|
|
store.link_thread_to_archived_worktree(thread_id_1, id, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| store.delete_archived_worktree(id, cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
let worktrees = store
|
|
.read_with(cx, |store, cx| {
|
|
store.get_archived_worktrees_for_thread(thread_id_1, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(worktrees.is_empty());
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_link_multiple_threads_to_archived_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let id = store
|
|
.read_with(cx, |store, cx| {
|
|
store.create_archived_worktree(
|
|
"/tmp/worktree".to_string(),
|
|
"/home/user/repo".to_string(),
|
|
None,
|
|
"abc123".to_string(),
|
|
"abc123".to_string(),
|
|
"original_000".to_string(),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let thread_id_1 = ThreadId::new();
|
|
let thread_id_2 = ThreadId::new();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| {
|
|
store.link_thread_to_archived_worktree(thread_id_1, id, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| {
|
|
store.link_thread_to_archived_worktree(thread_id_2, id, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let wt1 = store
|
|
.read_with(cx, |store, cx| {
|
|
store.get_archived_worktrees_for_thread(thread_id_1, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let wt2 = store
|
|
.read_with(cx, |store, cx| {
|
|
store.get_archived_worktrees_for_thread(thread_id_2, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(wt1.len(), 1);
|
|
assert_eq!(wt2.len(), 1);
|
|
assert_eq!(wt1[0].id, wt2[0].id);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_complete_worktree_restore_multiple_paths(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let original_paths = PathList::new(&[
|
|
Path::new("/projects/worktree-a"),
|
|
Path::new("/projects/worktree-b"),
|
|
Path::new("/other/unrelated"),
|
|
]);
|
|
let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
|
|
let thread_id = meta.thread_id;
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.save(meta, cx);
|
|
});
|
|
|
|
let replacements = vec![
|
|
(
|
|
PathBuf::from("/projects/worktree-a"),
|
|
PathBuf::from("/restored/worktree-a"),
|
|
),
|
|
(
|
|
PathBuf::from("/projects/worktree-b"),
|
|
PathBuf::from("/restored/worktree-b"),
|
|
),
|
|
];
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.complete_worktree_restore(thread_id, &replacements, cx);
|
|
});
|
|
|
|
let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
|
|
let entry = entry.unwrap();
|
|
let paths = entry.folder_paths().paths();
|
|
assert_eq!(paths.len(), 3);
|
|
assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
|
|
assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
|
|
assert!(paths.contains(&PathBuf::from("/other/unrelated")));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_complete_worktree_restore_preserves_unmatched_paths(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let original_paths =
|
|
PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
|
|
let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
|
|
let thread_id = meta.thread_id;
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.save(meta, cx);
|
|
});
|
|
|
|
let replacements = vec![
|
|
(
|
|
PathBuf::from("/projects/worktree-a"),
|
|
PathBuf::from("/new/worktree-a"),
|
|
),
|
|
(
|
|
PathBuf::from("/nonexistent/path"),
|
|
PathBuf::from("/should/not/appear"),
|
|
),
|
|
];
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.complete_worktree_restore(thread_id, &replacements, cx);
|
|
});
|
|
|
|
let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
|
|
let entry = entry.unwrap();
|
|
let paths = entry.folder_paths().paths();
|
|
assert_eq!(paths.len(), 2);
|
|
assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
|
|
assert!(paths.contains(&PathBuf::from("/other/path")));
|
|
assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_update_restored_worktree_paths_multiple(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let original_paths = PathList::new(&[
|
|
Path::new("/projects/worktree-a"),
|
|
Path::new("/projects/worktree-b"),
|
|
Path::new("/other/unrelated"),
|
|
]);
|
|
let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
|
|
let thread_id = meta.thread_id;
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.save(meta, cx);
|
|
});
|
|
|
|
let replacements = vec![
|
|
(
|
|
PathBuf::from("/projects/worktree-a"),
|
|
PathBuf::from("/restored/worktree-a"),
|
|
),
|
|
(
|
|
PathBuf::from("/projects/worktree-b"),
|
|
PathBuf::from("/restored/worktree-b"),
|
|
),
|
|
];
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.update_restored_worktree_paths(thread_id, &replacements, cx);
|
|
});
|
|
|
|
let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
|
|
let entry = entry.unwrap();
|
|
let paths = entry.folder_paths().paths();
|
|
assert_eq!(paths.len(), 3);
|
|
assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
|
|
assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
|
|
assert!(paths.contains(&PathBuf::from("/other/unrelated")));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_update_restored_worktree_paths_preserves_unmatched(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let original_paths =
|
|
PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
|
|
let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
|
|
let thread_id = meta.thread_id;
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.save(meta, cx);
|
|
});
|
|
|
|
let replacements = vec![
|
|
(
|
|
PathBuf::from("/projects/worktree-a"),
|
|
PathBuf::from("/new/worktree-a"),
|
|
),
|
|
(
|
|
PathBuf::from("/nonexistent/path"),
|
|
PathBuf::from("/should/not/appear"),
|
|
),
|
|
];
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.update_restored_worktree_paths(thread_id, &replacements, cx);
|
|
});
|
|
|
|
let entry = store.read_with(cx, |store, _cx| store.entry(thread_id).cloned());
|
|
let entry = entry.unwrap();
|
|
let paths = entry.folder_paths().paths();
|
|
assert_eq!(paths.len(), 2);
|
|
assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
|
|
assert!(paths.contains(&PathBuf::from("/other/path")));
|
|
assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_multiple_archived_worktrees_per_thread(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
|
|
|
let id1 = store
|
|
.read_with(cx, |store, cx| {
|
|
store.create_archived_worktree(
|
|
"/projects/worktree-a".to_string(),
|
|
"/home/user/repo".to_string(),
|
|
Some("branch-a".to_string()),
|
|
"staged_a".to_string(),
|
|
"unstaged_a".to_string(),
|
|
"original_000".to_string(),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let id2 = store
|
|
.read_with(cx, |store, cx| {
|
|
store.create_archived_worktree(
|
|
"/projects/worktree-b".to_string(),
|
|
"/home/user/repo".to_string(),
|
|
Some("branch-b".to_string()),
|
|
"staged_b".to_string(),
|
|
"unstaged_b".to_string(),
|
|
"original_000".to_string(),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let thread_id_1 = ThreadId::new();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| {
|
|
store.link_thread_to_archived_worktree(thread_id_1, id1, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
store
|
|
.read_with(cx, |store, cx| {
|
|
store.link_thread_to_archived_worktree(thread_id_1, id2, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let worktrees = store
|
|
.read_with(cx, |store, cx| {
|
|
store.get_archived_worktrees_for_thread(thread_id_1, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(worktrees.len(), 2);
|
|
|
|
let paths: Vec<&Path> = worktrees
|
|
.iter()
|
|
.map(|w| w.worktree_path.as_path())
|
|
.collect();
|
|
assert!(paths.contains(&Path::new("/projects/worktree-a")));
|
|
assert!(paths.contains(&Path::new("/projects/worktree-b")));
|
|
}
|
|
|
|
// ── Migration tests ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_thread_id_primary_key_migration_backfills_null_thread_ids() {
|
|
use db::sqlez::connection::Connection;
|
|
|
|
let connection =
|
|
Connection::open_memory(Some("test_thread_id_pk_migration_backfills_nulls"));
|
|
|
|
// Run migrations 0-6 (the old schema, before the thread_id PK migration).
|
|
let old_migrations: &[&str] = &ThreadMetadataDb::MIGRATIONS[..7];
|
|
connection
|
|
.migrate(ThreadMetadataDb::NAME, old_migrations, &mut |_, _, _| false)
|
|
.expect("old migrations should succeed");
|
|
|
|
// Insert rows: one with a thread_id, two without.
|
|
connection
|
|
.exec(
|
|
"INSERT INTO sidebar_threads \
|
|
(session_id, title, updated_at, thread_id) \
|
|
VALUES ('has-tid', 'Has ThreadId', '2025-01-01T00:00:00Z', X'0102030405060708090A0B0C0D0E0F10')",
|
|
)
|
|
.unwrap()()
|
|
.unwrap();
|
|
connection
|
|
.exec(
|
|
"INSERT INTO sidebar_threads \
|
|
(session_id, title, updated_at) \
|
|
VALUES ('no-tid-1', 'No ThreadId 1', '2025-01-02T00:00:00Z')",
|
|
)
|
|
.unwrap()()
|
|
.unwrap();
|
|
connection
|
|
.exec(
|
|
"INSERT INTO sidebar_threads \
|
|
(session_id, title, updated_at) \
|
|
VALUES ('no-tid-2', 'No ThreadId 2', '2025-01-03T00:00:00Z')",
|
|
)
|
|
.unwrap()()
|
|
.unwrap();
|
|
|
|
// Set up archived_git_worktrees + thread_archived_worktrees rows
|
|
// referencing the session without a thread_id.
|
|
connection
|
|
.exec(
|
|
"INSERT INTO archived_git_worktrees \
|
|
(id, worktree_path, main_repo_path, staged_commit_hash, unstaged_commit_hash, original_commit_hash) \
|
|
VALUES (1, '/wt', '/main', 'abc', 'def', '000')",
|
|
)
|
|
.unwrap()()
|
|
.unwrap();
|
|
connection
|
|
.exec(
|
|
"INSERT INTO thread_archived_worktrees \
|
|
(session_id, archived_worktree_id) \
|
|
VALUES ('no-tid-1', 1)",
|
|
)
|
|
.unwrap()()
|
|
.unwrap();
|
|
|
|
// Run all current migrations. sqlez skips the already-applied ones and
|
|
// runs the remaining migrations.
|
|
run_thread_metadata_migrations(&connection);
|
|
|
|
// All 3 rows should survive with non-NULL thread_ids.
|
|
let count: i64 = connection
|
|
.select_row_bound::<(), i64>("SELECT COUNT(*) FROM sidebar_threads")
|
|
.unwrap()(())
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(count, 3, "all 3 rows should survive the migration");
|
|
|
|
let null_count: i64 = connection
|
|
.select_row_bound::<(), i64>(
|
|
"SELECT COUNT(*) FROM sidebar_threads WHERE thread_id IS NULL",
|
|
)
|
|
.unwrap()(())
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
null_count, 0,
|
|
"no rows should have NULL thread_id after migration"
|
|
);
|
|
|
|
// The row that already had a thread_id should keep its original value.
|
|
let original_tid: Vec<u8> = connection
|
|
.select_row_bound::<&str, Vec<u8>>(
|
|
"SELECT thread_id FROM sidebar_threads WHERE session_id = ?",
|
|
)
|
|
.unwrap()("has-tid")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
original_tid,
|
|
vec![
|
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
|
0x0F, 0x10
|
|
],
|
|
"pre-existing thread_id should be preserved"
|
|
);
|
|
|
|
// The two rows that had NULL thread_ids should now have distinct non-empty blobs.
|
|
let generated_tid_1: Vec<u8> = connection
|
|
.select_row_bound::<&str, Vec<u8>>(
|
|
"SELECT thread_id FROM sidebar_threads WHERE session_id = ?",
|
|
)
|
|
.unwrap()("no-tid-1")
|
|
.unwrap()
|
|
.unwrap();
|
|
let generated_tid_2: Vec<u8> = connection
|
|
.select_row_bound::<&str, Vec<u8>>(
|
|
"SELECT thread_id FROM sidebar_threads WHERE session_id = ?",
|
|
)
|
|
.unwrap()("no-tid-2")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
generated_tid_1.len(),
|
|
16,
|
|
"generated thread_id should be 16 bytes"
|
|
);
|
|
assert_eq!(
|
|
generated_tid_2.len(),
|
|
16,
|
|
"generated thread_id should be 16 bytes"
|
|
);
|
|
assert_ne!(
|
|
generated_tid_1, generated_tid_2,
|
|
"each generated thread_id should be unique"
|
|
);
|
|
|
|
// The thread_archived_worktrees join row should have migrated
|
|
// using the backfilled thread_id from the session without a
|
|
// pre-existing thread_id.
|
|
let archived_count: i64 = connection
|
|
.select_row_bound::<(), i64>("SELECT COUNT(*) FROM thread_archived_worktrees")
|
|
.unwrap()(())
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
archived_count, 1,
|
|
"thread_archived_worktrees row should survive migration"
|
|
);
|
|
|
|
// The thread_archived_worktrees row should reference the
|
|
// backfilled thread_id of the 'no-tid-1' session.
|
|
let archived_tid: Vec<u8> = connection
|
|
.select_row_bound::<(), Vec<u8>>(
|
|
"SELECT thread_id FROM thread_archived_worktrees LIMIT 1",
|
|
)
|
|
.unwrap()(())
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
archived_tid, generated_tid_1,
|
|
"thread_archived_worktrees should reference the backfilled thread_id"
|
|
);
|
|
}
|
|
|
|
// ── ThreadWorktreePaths tests ──────────────────────────────────────
|
|
|
|
/// Helper to build a `ThreadWorktreePaths` from (main, folder) pairs.
|
|
fn make_worktree_paths(pairs: &[(&str, &str)]) -> WorktreePaths {
|
|
let (mains, folders): (Vec<&Path>, Vec<&Path>) = pairs
|
|
.iter()
|
|
.map(|(m, f)| (Path::new(*m), Path::new(*f)))
|
|
.unzip();
|
|
WorktreePaths::from_path_lists(PathList::new(&mains), PathList::new(&folders)).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_worktree_paths_full_add_then_remove_cycle() {
|
|
// Full scenario from the issue:
|
|
// 1. Start with linked worktree selectric → zed
|
|
// 2. Add cloud
|
|
// 3. Remove zed
|
|
|
|
let mut paths = make_worktree_paths(&[("/projects/zed", "/worktrees/selectric/zed")]);
|
|
|
|
// Step 2: add cloud
|
|
paths.add_path(Path::new("/projects/cloud"), Path::new("/projects/cloud"));
|
|
|
|
assert_eq!(paths.ordered_pairs().count(), 2);
|
|
assert_eq!(
|
|
paths.folder_path_list(),
|
|
&PathList::new(&[
|
|
Path::new("/worktrees/selectric/zed"),
|
|
Path::new("/projects/cloud"),
|
|
])
|
|
);
|
|
assert_eq!(
|
|
paths.main_worktree_path_list(),
|
|
&PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/cloud"),])
|
|
);
|
|
|
|
// Step 3: remove zed
|
|
paths.remove_main_path(Path::new("/projects/zed"));
|
|
|
|
assert_eq!(paths.ordered_pairs().count(), 1);
|
|
assert_eq!(
|
|
paths.folder_path_list(),
|
|
&PathList::new(&[Path::new("/projects/cloud")])
|
|
);
|
|
assert_eq!(
|
|
paths.main_worktree_path_list(),
|
|
&PathList::new(&[Path::new("/projects/cloud")])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_worktree_paths_add_is_idempotent() {
|
|
let mut paths = make_worktree_paths(&[("/projects/zed", "/projects/zed")]);
|
|
|
|
paths.add_path(Path::new("/projects/zed"), Path::new("/projects/zed"));
|
|
|
|
assert_eq!(paths.ordered_pairs().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_worktree_paths_remove_nonexistent_is_noop() {
|
|
let mut paths = make_worktree_paths(&[("/projects/zed", "/worktrees/selectric/zed")]);
|
|
|
|
paths.remove_main_path(Path::new("/projects/nonexistent"));
|
|
|
|
assert_eq!(paths.ordered_pairs().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_worktree_paths_from_path_lists_preserves_association() {
|
|
let folder = PathList::new(&[
|
|
Path::new("/worktrees/selectric/zed"),
|
|
Path::new("/projects/cloud"),
|
|
]);
|
|
let main = PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/cloud")]);
|
|
|
|
let paths = WorktreePaths::from_path_lists(main, folder).unwrap();
|
|
|
|
let pairs: Vec<_> = paths
|
|
.ordered_pairs()
|
|
.map(|(m, f)| (m.clone(), f.clone()))
|
|
.collect();
|
|
assert_eq!(pairs.len(), 2);
|
|
assert!(pairs.contains(&(
|
|
PathBuf::from("/projects/zed"),
|
|
PathBuf::from("/worktrees/selectric/zed")
|
|
)));
|
|
assert!(pairs.contains(&(
|
|
PathBuf::from("/projects/cloud"),
|
|
PathBuf::from("/projects/cloud")
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_worktree_paths_main_deduplicates_linked_worktrees() {
|
|
// Two linked worktrees of the same main repo: the main_worktree_path_list
|
|
// deduplicates because PathList stores unique sorted paths, but
|
|
// ordered_pairs still has both entries.
|
|
let paths = make_worktree_paths(&[
|
|
("/projects/zed", "/worktrees/selectric/zed"),
|
|
("/projects/zed", "/worktrees/feature/zed"),
|
|
]);
|
|
|
|
// main_worktree_path_list has the duplicate main path twice
|
|
// (PathList keeps all entries from its input)
|
|
assert_eq!(paths.ordered_pairs().count(), 2);
|
|
assert_eq!(
|
|
paths.folder_path_list(),
|
|
&PathList::new(&[
|
|
Path::new("/worktrees/selectric/zed"),
|
|
Path::new("/worktrees/feature/zed"),
|
|
])
|
|
);
|
|
assert_eq!(
|
|
paths.main_worktree_path_list(),
|
|
&PathList::new(&[Path::new("/projects/zed"), Path::new("/projects/zed"),])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_worktree_paths_mismatched_lengths_returns_error() {
|
|
let folder = PathList::new(&[
|
|
Path::new("/worktrees/selectric/zed"),
|
|
Path::new("/projects/cloud"),
|
|
]);
|
|
let main = PathList::new(&[Path::new("/projects/zed")]);
|
|
|
|
let result = WorktreePaths::from_path_lists(main, folder);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
/// Regression test: archiving a thread created in a git worktree must
|
|
/// preserve the thread's folder paths so that restoring it later does
|
|
/// not prompt the user to re-associate a project.
|
|
#[gpui::test]
|
|
async fn test_archived_thread_retains_paths_after_worktree_removal(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/worktrees/feature",
|
|
serde_json::json!({ "src": { "main.rs": "" } }),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs, [Path::new("/worktrees/feature")], cx).await;
|
|
let connection = StubAgentConnection::new();
|
|
|
|
let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
|
|
crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
|
|
|
|
let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
|
|
|
|
// Push content so the event handler saves metadata with the
|
|
// project's worktree paths.
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "Hello".into(), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
// Verify paths were saved correctly.
|
|
let (folder_paths_before, main_paths_before) = cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
let entry = store.entry(thread_id).unwrap();
|
|
assert!(
|
|
!entry.folder_paths().is_empty(),
|
|
"thread should have folder paths before archiving"
|
|
);
|
|
(
|
|
entry.folder_paths().clone(),
|
|
entry.main_worktree_paths().clone(),
|
|
)
|
|
});
|
|
|
|
// Archive the thread.
|
|
cx.update(|cx| {
|
|
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
|
|
store.archive(thread_id, None, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// Remove the worktree from the project, simulating what the
|
|
// archive flow does for linked git worktrees.
|
|
let worktree_id = cx.update(|cx| {
|
|
project
|
|
.read(cx)
|
|
.visible_worktrees(cx)
|
|
.next()
|
|
.unwrap()
|
|
.read(cx)
|
|
.id()
|
|
});
|
|
project.update(cx, |project, cx| {
|
|
project.remove_worktree(worktree_id, cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// Trigger a thread event after archiving + worktree removal.
|
|
// In production this happens when an async title-generation task
|
|
// completes after the thread was archived.
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.set_title("Generated title".into(), cx).detach();
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
// The archived thread must still have its original folder paths.
|
|
cx.read(|cx| {
|
|
let store = ThreadMetadataStore::global(cx).read(cx);
|
|
let entry = store.entry(thread_id).unwrap();
|
|
assert!(entry.archived, "thread should still be archived");
|
|
assert_eq!(
|
|
entry.display_title().as_ref(),
|
|
"Generated title",
|
|
"title should still be updated for archived threads"
|
|
);
|
|
assert_eq!(
|
|
entry.folder_paths(),
|
|
&folder_paths_before,
|
|
"archived thread must retain its folder paths after worktree \
|
|
removal + subsequent thread event, otherwise restoring it \
|
|
will prompt the user to re-associate a project"
|
|
);
|
|
assert_eq!(
|
|
entry.main_worktree_paths(),
|
|
&main_paths_before,
|
|
"archived thread must retain its main worktree paths after \
|
|
worktree removal + subsequent thread event"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_collab_guest_threads_not_saved_to_metadata_store(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let project = Project::test(fs, [Path::new("/project-a")], cx).await;
|
|
|
|
let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
|
|
crate::test_support::open_thread_with_connection(
|
|
&panel,
|
|
StubAgentConnection::new(),
|
|
&mut vcx,
|
|
);
|
|
let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "hello".into(), cx);
|
|
thread.set_title("Thread".into(), cx).detach();
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
// Confirm the thread is in the store while the project is local.
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
assert!(
|
|
store.read(cx).entry(thread_id).is_some(),
|
|
"thread must be in the store while the project is local"
|
|
);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
store.update(cx, |store, cx| {
|
|
store.delete(thread_id, cx);
|
|
});
|
|
});
|
|
project.update(cx, |project, _cx| {
|
|
project.mark_as_collab_for_testing();
|
|
});
|
|
|
|
thread.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "more content".into(), cx);
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
assert!(
|
|
store.read(cx).entry(thread_id).is_none(),
|
|
"threads must not be persisted while the project is a collab guest session"
|
|
);
|
|
});
|
|
}
|
|
|
|
// When a worktree is added to a collab project, update_thread_work_dirs
|
|
// fires with the new worktree paths. Without an is_via_collab() guard it
|
|
// overwrites the stored paths of any retained or active local threads with
|
|
// the new (expanded) path set, corrupting metadata that belonged to the
|
|
// guest's own local project.
|
|
#[gpui::test]
|
|
async fn test_collab_guest_retained_thread_paths_not_overwritten_on_worktree_change(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/project-a", serde_json::json!({})).await;
|
|
fs.insert_tree("/project-b", serde_json::json!({})).await;
|
|
let project = Project::test(fs, [Path::new("/project-a")], cx).await;
|
|
|
|
let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
|
|
|
|
// Open thread A and give it content so its metadata is saved with /project-a.
|
|
crate::test_support::open_thread_with_connection(
|
|
&panel,
|
|
StubAgentConnection::new(),
|
|
&mut vcx,
|
|
);
|
|
let thread_a_id = crate::test_support::active_thread_id(&panel, &vcx);
|
|
let thread_a = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
|
|
thread_a.update_in(&mut vcx, |thread, _window, cx| {
|
|
thread.push_user_content_block(None, "hello".into(), cx);
|
|
thread.set_title("Thread A".into(), cx).detach();
|
|
});
|
|
vcx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let entry = store.read(cx).entry(thread_a_id).unwrap();
|
|
assert_eq!(
|
|
entry.folder_paths().paths(),
|
|
&[std::path::PathBuf::from("/project-a")],
|
|
"thread A must be saved with /project-a before collab"
|
|
);
|
|
});
|
|
|
|
// Open thread B, making thread A a retained thread in the panel.
|
|
crate::test_support::open_thread_with_connection(
|
|
&panel,
|
|
StubAgentConnection::new(),
|
|
&mut vcx,
|
|
);
|
|
vcx.run_until_parked();
|
|
|
|
// Transition the project into collab mode (simulates joining as a guest).
|
|
project.update(cx, |project, _cx| {
|
|
project.mark_as_collab_for_testing();
|
|
});
|
|
|
|
// Add a second worktree. For a real collab guest this would be one of
|
|
// the host's worktrees arriving via the collab protocol, but here we
|
|
// use a local path because the test infrastructure cannot easily produce
|
|
// a remote worktree with a fully-scanned root entry.
|
|
//
|
|
// This fires WorktreeAdded → update_thread_work_dirs. Without an
|
|
// is_via_collab() guard that call overwrites the stored paths of
|
|
// retained thread A from {/project-a} to {/project-a, /project-b},
|
|
// polluting its metadata with a path it never belonged to.
|
|
project
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree(Path::new("/project-b"), true, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
vcx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let store = ThreadMetadataStore::global(cx);
|
|
let entry = store
|
|
.read(cx)
|
|
.entry(thread_a_id)
|
|
.expect("thread A must still exist in the store");
|
|
assert_eq!(
|
|
entry.folder_paths().paths(),
|
|
&[std::path::PathBuf::from("/project-a")],
|
|
"retained thread A's stored path must not be updated while the project is via collab"
|
|
);
|
|
});
|
|
}
|
|
}
|