mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Cherry-pick: agent: Add ability to import threads from other channels (#54021)
Cherry-pick of #54002 to the v0.233.x preview branch. Conflict resolution notes: - Removed `WHERE session_id IS NOT NULL` from `LIST_QUERY` since the draft threads cleanup PR (#54014) is not on this branch yet. - Updated tests to use foreign channels so they pass on all of Dev, Nightly, Preview, and Stable. See https://github.com/zed-industries/zed/pull/54022 for cherry picking back to main. Release Notes: - N/A Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
parent
b8b7aad70a
commit
5eca965473
7 changed files with 618 additions and 96 deletions
|
|
@ -79,7 +79,10 @@ pub(crate) use model_selector::ModelSelector;
|
|||
pub(crate) use model_selector_popover::ModelSelectorPopover;
|
||||
pub(crate) use thread_history::ThreadHistory;
|
||||
pub(crate) use thread_history_view::*;
|
||||
pub use thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
|
||||
pub use thread_import::{
|
||||
AcpThreadImportOnboarding, CrossChannelImportOnboarding, ThreadImportModal,
|
||||
channels_with_threads, import_threads_from_other_channels,
|
||||
};
|
||||
use zed_actions;
|
||||
|
||||
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
|
||||
|
|
@ -195,6 +198,8 @@ actions!(
|
|||
ScrollOutputToPreviousMessage,
|
||||
/// Scroll the output to the next user message.
|
||||
ScrollOutputToNextMessage,
|
||||
/// Import agent threads from other Zed release channels (e.g. Preview, Nightly).
|
||||
ImportThreadsFromOtherChannels,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -511,6 +516,17 @@ pub fn init(
|
|||
})
|
||||
.detach();
|
||||
cx.observe_new(ManageProfilesModal::register).detach();
|
||||
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
|
||||
workspace.register_action(
|
||||
|workspace: &mut Workspace,
|
||||
_: &ImportThreadsFromOtherChannels,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Workspace>| {
|
||||
import_threads_from_other_channels(workspace, cx);
|
||||
},
|
||||
);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Update command palette filter based on AI settings
|
||||
update_command_palette_filter(cx);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use agent_client_protocol as acp;
|
|||
use chrono::Utc;
|
||||
use collections::HashSet;
|
||||
use db::kvp::Dismissable;
|
||||
use db::sqlez;
|
||||
use fs::Fs;
|
||||
use futures::FutureExt as _;
|
||||
use gpui::{
|
||||
|
|
@ -12,6 +13,7 @@ use gpui::{
|
|||
};
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
use project::{AgentId, AgentRegistryStore, AgentServerStore};
|
||||
use release_channel::ReleaseChannel;
|
||||
use remote::RemoteConnectionOptions;
|
||||
use ui::{
|
||||
Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
|
||||
|
|
@ -27,6 +29,7 @@ use crate::{
|
|||
};
|
||||
|
||||
pub struct AcpThreadImportOnboarding;
|
||||
pub struct CrossChannelImportOnboarding;
|
||||
|
||||
impl AcpThreadImportOnboarding {
|
||||
pub fn dismissed(cx: &App) -> bool {
|
||||
|
|
@ -42,6 +45,40 @@ impl Dismissable for AcpThreadImportOnboarding {
|
|||
const KEY: &'static str = "dismissed-acp-thread-import";
|
||||
}
|
||||
|
||||
impl CrossChannelImportOnboarding {
|
||||
pub fn dismissed(cx: &App) -> bool {
|
||||
<Self as Dismissable>::dismissed(cx)
|
||||
}
|
||||
|
||||
pub fn dismiss(cx: &mut App) {
|
||||
<Self as Dismissable>::set_dismissed(true, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Dismissable for CrossChannelImportOnboarding {
|
||||
const KEY: &'static str = "dismissed-cross-channel-thread-import";
|
||||
}
|
||||
|
||||
/// Returns the list of non-Dev, non-current release channels that have
|
||||
/// at least one thread in their database. The result is suitable for
|
||||
/// building a user-facing message ("from Zed Preview and Nightly").
|
||||
pub fn channels_with_threads(cx: &App) -> Vec<ReleaseChannel> {
|
||||
let Some(current_channel) = ReleaseChannel::try_global(cx) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let database_dir = paths::database_dir();
|
||||
|
||||
ReleaseChannel::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|channel| {
|
||||
*channel != current_channel
|
||||
&& *channel != ReleaseChannel::Dev
|
||||
&& channel_has_threads(database_dir, *channel)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AgentEntry {
|
||||
agent_id: AgentId,
|
||||
|
|
@ -536,11 +573,121 @@ fn collect_importable_threads(
|
|||
to_insert
|
||||
}
|
||||
|
||||
pub fn import_threads_from_other_channels(_workspace: &mut Workspace, cx: &mut Context<Workspace>) {
|
||||
let database_dir = paths::database_dir().clone();
|
||||
import_threads_from_other_channels_in(database_dir, cx);
|
||||
}
|
||||
|
||||
fn import_threads_from_other_channels_in(
|
||||
database_dir: std::path::PathBuf,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let current_channel = ReleaseChannel::global(cx);
|
||||
|
||||
let existing_thread_ids: HashSet<ThreadId> = ThreadMetadataStore::global(cx)
|
||||
.read(cx)
|
||||
.entries()
|
||||
.map(|metadata| metadata.thread_id)
|
||||
.collect();
|
||||
|
||||
let workspace_handle = cx.weak_entity();
|
||||
cx.spawn(async move |_this, cx| {
|
||||
let mut imported_threads = Vec::new();
|
||||
|
||||
for channel in &ReleaseChannel::ALL {
|
||||
if *channel == current_channel || *channel == ReleaseChannel::Dev {
|
||||
continue;
|
||||
}
|
||||
|
||||
match read_threads_from_channel(&database_dir, *channel) {
|
||||
Ok(threads) => {
|
||||
let new_threads = threads
|
||||
.into_iter()
|
||||
.filter(|thread| !existing_thread_ids.contains(&thread.thread_id));
|
||||
imported_threads.extend(new_threads);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Failed to read threads from {} channel database: {}",
|
||||
channel.dev_name(),
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let imported_count = imported_threads.len();
|
||||
|
||||
cx.update(|cx| {
|
||||
ThreadMetadataStore::global(cx)
|
||||
.update(cx, |store, cx| store.save_all(imported_threads, cx));
|
||||
|
||||
show_cross_channel_import_toast(&workspace_handle, imported_count, cx);
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn channel_has_threads(database_dir: &std::path::Path, channel: ReleaseChannel) -> bool {
|
||||
let db_path = db::db_path(database_dir, channel);
|
||||
if !db_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
|
||||
connection
|
||||
.select_row::<bool>("SELECT 1 FROM sidebar_threads LIMIT 1")
|
||||
.ok()
|
||||
.and_then(|mut query| query().ok().flatten())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn read_threads_from_channel(
|
||||
database_dir: &std::path::Path,
|
||||
channel: ReleaseChannel,
|
||||
) -> anyhow::Result<Vec<ThreadMetadata>> {
|
||||
let db_path = db::db_path(database_dir, channel);
|
||||
if !db_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
|
||||
crate::thread_metadata_store::list_thread_metadata_from_connection(&connection)
|
||||
}
|
||||
|
||||
fn show_cross_channel_import_toast(
|
||||
workspace: &WeakEntity<Workspace>,
|
||||
imported_count: usize,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let status_toast = if imported_count == 0 {
|
||||
StatusToast::new("No new threads found to import.", cx, |this, _cx| {
|
||||
this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
|
||||
.dismiss_button(true)
|
||||
})
|
||||
} else {
|
||||
let message = if imported_count == 1 {
|
||||
"Imported 1 thread from other channels.".to_string()
|
||||
} else {
|
||||
format!("Imported {imported_count} threads from other channels.")
|
||||
};
|
||||
StatusToast::new(message, cx, |this, _cx| {
|
||||
this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
|
||||
.dismiss_button(true)
|
||||
})
|
||||
};
|
||||
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.toggle_status_toast(status_toast, cx);
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acp_thread::AgentSessionInfo;
|
||||
use chrono::Utc;
|
||||
use gpui::TestAppContext;
|
||||
use std::path::Path;
|
||||
use workspace::PathList;
|
||||
|
||||
|
|
@ -732,4 +879,212 @@ mod tests {
|
|||
let result = collect_importable_threads(sessions_by_agent, existing);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
fn create_channel_db(
|
||||
db_dir: &std::path::Path,
|
||||
channel: ReleaseChannel,
|
||||
) -> db::sqlez::connection::Connection {
|
||||
let db_path = db::db_path(db_dir, channel);
|
||||
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
|
||||
let connection = db::sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
|
||||
crate::thread_metadata_store::run_thread_metadata_migrations(&connection);
|
||||
connection
|
||||
}
|
||||
|
||||
fn insert_thread(
|
||||
connection: &db::sqlez::connection::Connection,
|
||||
title: &str,
|
||||
updated_at: &str,
|
||||
archived: bool,
|
||||
) {
|
||||
let thread_id = uuid::Uuid::new_v4();
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
connection
|
||||
.exec_bound::<(uuid::Uuid, &str, &str, &str, bool)>(
|
||||
"INSERT INTO sidebar_threads \
|
||||
(thread_id, session_id, title, updated_at, archived) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.unwrap()((thread_id, session_id.as_str(), title, updated_at, archived))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_returns_empty_when_channel_db_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
|
||||
assert!(threads.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserves_archived_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let connection = create_channel_db(dir.path(), ReleaseChannel::Nightly);
|
||||
|
||||
insert_thread(&connection, "Active Thread", "2025-01-15T10:00:00Z", false);
|
||||
insert_thread(&connection, "Archived Thread", "2025-01-15T09:00:00Z", true);
|
||||
drop(connection);
|
||||
|
||||
let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
|
||||
assert_eq!(threads.len(), 2);
|
||||
|
||||
let active = threads
|
||||
.iter()
|
||||
.find(|t| t.display_title().as_ref() == "Active Thread")
|
||||
.unwrap();
|
||||
assert!(!active.archived);
|
||||
|
||||
let archived = threads
|
||||
.iter()
|
||||
.find(|t| t.display_title().as_ref() == "Archived Thread")
|
||||
.unwrap();
|
||||
assert!(archived.archived);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
let fs = 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);
|
||||
release_channel::init("0.0.0".parse().unwrap(), cx);
|
||||
<dyn fs::Fs>::set_global(fs, cx);
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Returns two release channels that are not the current one and not Dev.
|
||||
/// This ensures tests work regardless of which release channel branch
|
||||
/// they run on.
|
||||
fn foreign_channels(cx: &TestAppContext) -> (ReleaseChannel, ReleaseChannel) {
|
||||
let current = cx.update(|cx| ReleaseChannel::global(cx));
|
||||
let mut channels = ReleaseChannel::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|ch| *ch != current && *ch != ReleaseChannel::Dev);
|
||||
(channels.next().unwrap(), channels.next().unwrap())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_import_threads_from_other_channels(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database_dir = dir.path().to_path_buf();
|
||||
|
||||
let (channel_a, channel_b) = foreign_channels(cx);
|
||||
|
||||
// Set up databases for two foreign channels.
|
||||
let db_a = create_channel_db(dir.path(), channel_a);
|
||||
insert_thread(&db_a, "Thread A1", "2025-01-15T10:00:00Z", false);
|
||||
insert_thread(&db_a, "Thread A2", "2025-01-15T11:00:00Z", true);
|
||||
drop(db_a);
|
||||
|
||||
let db_b = create_channel_db(dir.path(), channel_b);
|
||||
insert_thread(&db_b, "Thread B1", "2025-01-15T12:00:00Z", false);
|
||||
drop(db_b);
|
||||
|
||||
// Create a workspace and run the import.
|
||||
let fs = fs::FakeFs::new(cx.executor());
|
||||
let project = project::Project::test(fs, [], cx).await;
|
||||
let multi_workspace =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let workspace_entity = multi_workspace
|
||||
.read_with(cx, |mw, _cx| mw.workspace().clone())
|
||||
.unwrap();
|
||||
let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
|
||||
import_threads_from_other_channels_in(database_dir, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify all three threads were imported into the store.
|
||||
cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
let store = store.read(cx);
|
||||
let titles: collections::HashSet<String> = store
|
||||
.entries()
|
||||
.map(|m| m.display_title().to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(titles.len(), 3);
|
||||
assert!(titles.contains("Thread A1"));
|
||||
assert!(titles.contains("Thread A2"));
|
||||
assert!(titles.contains("Thread B1"));
|
||||
|
||||
// Verify archived state is preserved.
|
||||
let thread_a2 = store
|
||||
.entries()
|
||||
.find(|m| m.display_title().as_ref() == "Thread A2")
|
||||
.unwrap();
|
||||
assert!(thread_a2.archived);
|
||||
|
||||
let thread_b1 = store
|
||||
.entries()
|
||||
.find(|m| m.display_title().as_ref() == "Thread B1")
|
||||
.unwrap();
|
||||
assert!(!thread_b1.archived);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_import_skips_already_existing_threads(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database_dir = dir.path().to_path_buf();
|
||||
|
||||
let (channel_a, _) = foreign_channels(cx);
|
||||
|
||||
// Set up a database for a foreign channel.
|
||||
let db_a = create_channel_db(dir.path(), channel_a);
|
||||
insert_thread(&db_a, "Thread A", "2025-01-15T10:00:00Z", false);
|
||||
insert_thread(&db_a, "Thread B", "2025-01-15T11:00:00Z", false);
|
||||
drop(db_a);
|
||||
|
||||
// Read the threads so we can pre-populate one into the store.
|
||||
let foreign_threads = read_threads_from_channel(dir.path(), channel_a).unwrap();
|
||||
let thread_a = foreign_threads
|
||||
.iter()
|
||||
.find(|t| t.display_title().as_ref() == "Thread A")
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
// Pre-populate Thread A into the store.
|
||||
cx.update(|cx| {
|
||||
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(thread_a, cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Run the import.
|
||||
let fs = fs::FakeFs::new(cx.executor());
|
||||
let project = project::Project::test(fs, [], cx).await;
|
||||
let multi_workspace =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let workspace_entity = multi_workspace
|
||||
.read_with(cx, |mw, _cx| mw.workspace().clone())
|
||||
.unwrap();
|
||||
let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
|
||||
import_threads_from_other_channels_in(database_dir, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify only Thread B was added (Thread A already existed).
|
||||
cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
let store = store.read(cx);
|
||||
assert_eq!(store.entries().count(), 2);
|
||||
|
||||
let titles: collections::HashSet<String> = store
|
||||
.entries()
|
||||
.map(|m| m.display_title().to_string())
|
||||
.collect();
|
||||
assert!(titles.contains("Thread A"));
|
||||
assert!(titles.contains("Thread B"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,31 @@ impl Column for ThreadId {
|
|||
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);
|
||||
|
|
@ -1268,13 +1293,17 @@ impl ThreadMetadataDb {
|
|||
)?()
|
||||
}
|
||||
|
||||
const LIST_QUERY: &str = "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 \
|
||||
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>(
|
||||
"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 \
|
||||
ORDER BY updated_at DESC"
|
||||
)?()
|
||||
self.select::<ThreadMetadata>(Self::LIST_QUERY)?()
|
||||
}
|
||||
|
||||
/// Upsert metadata for a thread.
|
||||
|
|
@ -1683,7 +1712,7 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
fn run_thread_metadata_migrations(cx: &mut TestAppContext) {
|
||||
fn run_store_migrations(cx: &mut TestAppContext) {
|
||||
clear_thread_metadata_remote_connection_backfill(cx);
|
||||
cx.update(|cx| {
|
||||
let migration_task = migrate_thread_metadata(cx);
|
||||
|
|
@ -1962,7 +1991,7 @@ mod tests {
|
|||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
run_thread_metadata_migrations(cx);
|
||||
run_store_migrations(cx);
|
||||
|
||||
let list = cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
|
|
@ -2053,7 +2082,7 @@ mod tests {
|
|||
save_task.await.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
run_thread_metadata_migrations(cx);
|
||||
run_store_migrations(cx);
|
||||
|
||||
let list = cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
|
|
@ -2191,7 +2220,7 @@ mod tests {
|
|||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
run_thread_metadata_migrations(cx);
|
||||
run_store_migrations(cx);
|
||||
|
||||
let list = cx.update(|cx| {
|
||||
let store = ThreadMetadataStore::global(cx);
|
||||
|
|
@ -3371,13 +3400,7 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
// Run all migrations (0-7). sqlez skips 0-6 and runs only migration 7.
|
||||
connection
|
||||
.migrate(
|
||||
ThreadMetadataDb::NAME,
|
||||
ThreadMetadataDb::MIGRATIONS,
|
||||
&mut |_, _, _| false,
|
||||
)
|
||||
.expect("new migration should succeed");
|
||||
run_thread_metadata_migrations(&connection);
|
||||
|
||||
// All 3 rows should survive with non-NULL thread_ids.
|
||||
let count: i64 = connection
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ pub use sqlez_macros;
|
|||
pub use uuid;
|
||||
|
||||
pub use release_channel::RELEASE_CHANNEL;
|
||||
use release_channel::ReleaseChannel;
|
||||
use sqlez::domain::Migrator;
|
||||
use sqlez::thread_safe_connection::ThreadSafeConnection;
|
||||
use sqlez_macros::sql;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{LazyLock, atomic::Ordering};
|
||||
use util::{ResultExt, maybe};
|
||||
|
|
@ -61,8 +62,7 @@ impl AppDatabase {
|
|||
/// migrations in dependency order.
|
||||
pub fn new() -> Self {
|
||||
let db_dir = database_dir();
|
||||
let scope = RELEASE_CHANNEL.dev_name();
|
||||
let connection = smol::block_on(open_db::<AppMigrator>(db_dir, scope));
|
||||
let connection = smol::block_on(open_db::<AppMigrator>(db_dir, *RELEASE_CHANNEL));
|
||||
Self(connection)
|
||||
}
|
||||
|
||||
|
|
@ -139,23 +139,55 @@ const DB_FILE_NAME: &str = "db.sqlite";
|
|||
|
||||
pub static ALL_FILE_DB_FAILED: LazyLock<AtomicBool> = LazyLock::new(|| AtomicBool::new(false));
|
||||
|
||||
/// A type that can be used as a database scope for path construction.
|
||||
pub trait DbScope {
|
||||
fn scope_name(&self) -> &str;
|
||||
}
|
||||
|
||||
impl DbScope for ReleaseChannel {
|
||||
fn scope_name(&self) -> &str {
|
||||
self.dev_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// A database scope shared across all release channels.
|
||||
pub struct GlobalDbScope;
|
||||
|
||||
impl DbScope for GlobalDbScope {
|
||||
fn scope_name(&self) -> &str {
|
||||
"global"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the `AppDatabase` SQLite file for the given scope
|
||||
/// under `db_dir`.
|
||||
pub fn db_path(db_dir: &Path, scope: impl DbScope) -> PathBuf {
|
||||
db_dir
|
||||
.join(format!("0-{}", scope.scope_name()))
|
||||
.join(DB_FILE_NAME)
|
||||
}
|
||||
|
||||
/// Open or create a database at the given directory path.
|
||||
/// This will retry a couple times if there are failures. If opening fails once, the db directory
|
||||
/// is moved to a backup folder and a new one is created. If that fails, a shared in memory db is created.
|
||||
/// In either case, static variables are set so that the user can be notified.
|
||||
pub async fn open_db<M: Migrator + 'static>(db_dir: &Path, scope: &str) -> ThreadSafeConnection {
|
||||
pub async fn open_db<M: Migrator + 'static>(
|
||||
db_dir: &Path,
|
||||
scope: impl DbScope,
|
||||
) -> ThreadSafeConnection {
|
||||
if *ZED_STATELESS {
|
||||
return open_fallback_db::<M>().await;
|
||||
}
|
||||
|
||||
let main_db_dir = db_dir.join(format!("0-{}", scope));
|
||||
let db_path = db_path(db_dir, scope);
|
||||
|
||||
let connection = maybe!(async {
|
||||
smol::fs::create_dir_all(&main_db_dir)
|
||||
.await
|
||||
.context("Could not create db directory")
|
||||
.log_err()?;
|
||||
let db_path = main_db_dir.join(Path::new(DB_FILE_NAME));
|
||||
if let Some(parent) = db_path.parent() {
|
||||
smol::fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Could not create db directory")
|
||||
.log_err()?;
|
||||
}
|
||||
open_main_db::<M>(&db_path).await
|
||||
})
|
||||
.await;
|
||||
|
|
@ -289,11 +321,7 @@ mod tests {
|
|||
.prefix("DbTests")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let _bad_db = open_db::<BadDB>(
|
||||
tempdir.path(),
|
||||
release_channel::ReleaseChannel::Dev.dev_name(),
|
||||
)
|
||||
.await;
|
||||
let _bad_db = open_db::<BadDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
|
||||
}
|
||||
|
||||
/// Test that DB exists but corrupted (causing recreate)
|
||||
|
|
@ -320,19 +348,12 @@ mod tests {
|
|||
.tempdir()
|
||||
.unwrap();
|
||||
{
|
||||
let corrupt_db = open_db::<CorruptedDB>(
|
||||
tempdir.path(),
|
||||
release_channel::ReleaseChannel::Dev.dev_name(),
|
||||
)
|
||||
.await;
|
||||
let corrupt_db =
|
||||
open_db::<CorruptedDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
|
||||
assert!(corrupt_db.persistent());
|
||||
}
|
||||
|
||||
let good_db = open_db::<GoodDB>(
|
||||
tempdir.path(),
|
||||
release_channel::ReleaseChannel::Dev.dev_name(),
|
||||
)
|
||||
.await;
|
||||
let good_db = open_db::<GoodDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
|
||||
assert!(
|
||||
good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
|
||||
.unwrap()
|
||||
|
|
@ -366,11 +387,8 @@ mod tests {
|
|||
.unwrap();
|
||||
{
|
||||
// Setup the bad database
|
||||
let corrupt_db = open_db::<CorruptedDB>(
|
||||
tempdir.path(),
|
||||
release_channel::ReleaseChannel::Dev.dev_name(),
|
||||
)
|
||||
.await;
|
||||
let corrupt_db =
|
||||
open_db::<CorruptedDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
|
||||
assert!(corrupt_db.persistent());
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +399,7 @@ mod tests {
|
|||
let guard = thread::spawn(move || {
|
||||
let good_db = smol::block_on(open_db::<GoodDB>(
|
||||
tmp_path.as_path(),
|
||||
release_channel::ReleaseChannel::Dev.dev_name(),
|
||||
release_channel::ReleaseChannel::Dev,
|
||||
));
|
||||
assert!(
|
||||
good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
|
||||
|
|
|
|||
|
|
@ -244,7 +244,8 @@ static GLOBAL_KEY_VALUE_STORE: std::sync::LazyLock<GlobalKeyValueStore> =
|
|||
std::sync::LazyLock::new(|| {
|
||||
let db_dir = crate::database_dir();
|
||||
GlobalKeyValueStore(smol::block_on(crate::open_db::<GlobalKeyValueStore>(
|
||||
db_dir, "global",
|
||||
db_dir,
|
||||
crate::GlobalDbScope,
|
||||
)))
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,14 @@ pub fn init_test(app_version: Version, release_channel: ReleaseChannel, cx: &mut
|
|||
}
|
||||
|
||||
impl ReleaseChannel {
|
||||
/// All release channels.
|
||||
pub const ALL: [ReleaseChannel; 4] = [
|
||||
ReleaseChannel::Dev,
|
||||
ReleaseChannel::Nightly,
|
||||
ReleaseChannel::Preview,
|
||||
ReleaseChannel::Stable,
|
||||
];
|
||||
|
||||
/// Returns the global [`ReleaseChannel`].
|
||||
pub fn global(cx: &App) -> Self {
|
||||
cx.global::<GlobalReleaseChannel>().0
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ use agent_ui::threads_archive_view::{
|
|||
ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
|
||||
};
|
||||
use agent_ui::{
|
||||
AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread,
|
||||
RemoveSelectedThread, ThreadId, ThreadImportModal,
|
||||
AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, CrossChannelImportOnboarding,
|
||||
DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, ThreadId, ThreadImportModal,
|
||||
channels_with_threads, import_threads_from_other_channels,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use editor::Editor;
|
||||
|
|
@ -376,6 +377,12 @@ pub struct Sidebar {
|
|||
recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
|
||||
project_header_menu_ix: Option<usize>,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
/// For the thread import banners, if there is just one we show "Import
|
||||
/// Threads" but if we are showing both the external agents and other
|
||||
/// channels import banners then we change the text to disambiguate the
|
||||
/// buttons. This field tracks whether we were using verbose labels so they
|
||||
/// can stay stable after dismissing one of the banners.
|
||||
import_banners_use_verbose_labels: Option<bool>,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
|
|
@ -464,6 +471,7 @@ impl Sidebar {
|
|||
recent_projects_popover_handle: PopoverMenuHandle::default(),
|
||||
project_header_menu_ix: None,
|
||||
_subscriptions: Vec::new(),
|
||||
import_banners_use_verbose_labels: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4481,51 +4489,72 @@ impl Sidebar {
|
|||
has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
|
||||
}
|
||||
|
||||
fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let description = "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.";
|
||||
fn render_acp_import_onboarding(
|
||||
&mut self,
|
||||
verbose_labels: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let on_import = cx.listener(|this, _, window, cx| {
|
||||
this.show_archive(window, cx);
|
||||
this.show_thread_import_modal(window, cx);
|
||||
});
|
||||
render_import_onboarding_banner(
|
||||
"acp",
|
||||
"Looking for threads from external agents?",
|
||||
"Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.",
|
||||
if verbose_labels {
|
||||
"Import Threads from External Agents"
|
||||
} else {
|
||||
"Import Threads"
|
||||
},
|
||||
|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx),
|
||||
on_import,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
let bg = cx.theme().colors().text_accent;
|
||||
fn should_render_cross_channel_import_onboarding(&self, cx: &App) -> bool {
|
||||
!CrossChannelImportOnboarding::dismissed(cx) && !channels_with_threads(cx).is_empty()
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(linear_gradient(
|
||||
360.,
|
||||
linear_color_stop(bg.opacity(0.06), 1.),
|
||||
linear_color_stop(bg.opacity(0.), 0.),
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.child(Label::new("Looking for threads from external agents?"))
|
||||
.child(
|
||||
IconButton::new("close-onboarding", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
|
||||
),
|
||||
)
|
||||
.child(Label::new(description).color(Color::Muted).mb_2())
|
||||
.child(
|
||||
Button::new("import-acp", "Import Threads")
|
||||
.full_width()
|
||||
.style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
|
||||
.label_size(LabelSize::Small)
|
||||
.start_icon(
|
||||
Icon::new(IconName::ThreadImport)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.show_archive(window, cx);
|
||||
this.show_thread_import_modal(window, cx);
|
||||
})),
|
||||
)
|
||||
fn render_cross_channel_import_onboarding(
|
||||
&mut self,
|
||||
verbose_labels: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let channels = channels_with_threads(cx);
|
||||
let channel_names = channels
|
||||
.iter()
|
||||
.map(|channel| channel.display_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ");
|
||||
|
||||
let description = format!(
|
||||
"Import threads from {} to continue where you left off.",
|
||||
channel_names
|
||||
);
|
||||
|
||||
let on_import = cx.listener(|this, _, _window, cx| {
|
||||
CrossChannelImportOnboarding::dismiss(cx);
|
||||
if let Some(workspace) = this.active_workspace(cx) {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
import_threads_from_other_channels(workspace, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
render_import_onboarding_banner(
|
||||
"channel",
|
||||
"Threads found from other channels",
|
||||
description,
|
||||
if verbose_labels {
|
||||
"Import Threads from Other Channels"
|
||||
} else {
|
||||
"Import Threads"
|
||||
},
|
||||
|_, _window, cx| CrossChannelImportOnboarding::dismiss(cx),
|
||||
on_import,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_archive(&mut self, _: &ViewAllThreads, window: &mut Window, cx: &mut Context<Self>) {
|
||||
|
|
@ -4606,6 +4635,66 @@ impl Sidebar {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_import_onboarding_banner(
|
||||
id: impl Into<SharedString>,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
button_label: impl Into<SharedString>,
|
||||
on_dismiss: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
on_import: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let id: SharedString = id.into();
|
||||
let bg = cx.theme().colors().text_accent;
|
||||
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(linear_gradient(
|
||||
360.,
|
||||
linear_color_stop(bg.opacity(0.06), 1.),
|
||||
linear_color_stop(bg.opacity(0.), 0.),
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.flex_wrap()
|
||||
.child(Label::new(title).size(LabelSize::Small))
|
||||
.child(
|
||||
IconButton::new(
|
||||
SharedString::from(format!("close-{id}-onboarding")),
|
||||
IconName::Close,
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(on_dismiss),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Label::new(description)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("import-{id}")), button_label)
|
||||
.full_width()
|
||||
.style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
|
||||
.label_size(LabelSize::Small)
|
||||
.start_icon(
|
||||
Icon::new(IconName::ThreadImport)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.on_click(on_import),
|
||||
)
|
||||
}
|
||||
|
||||
impl WorkspaceSidebar for Sidebar {
|
||||
fn width(&self, _cx: &App) -> Pixels {
|
||||
self.width
|
||||
|
|
@ -4771,8 +4860,20 @@ impl Render for Sidebar {
|
|||
}),
|
||||
SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
|
||||
})
|
||||
.when(self.should_render_acp_import_onboarding(cx), |this| {
|
||||
this.child(self.render_acp_import_onboarding(cx))
|
||||
.map(|this| {
|
||||
let show_acp = self.should_render_acp_import_onboarding(cx);
|
||||
let show_cross_channel = self.should_render_cross_channel_import_onboarding(cx);
|
||||
|
||||
let verbose = *self
|
||||
.import_banners_use_verbose_labels
|
||||
.get_or_insert(show_acp && show_cross_channel);
|
||||
|
||||
this.when(show_acp, |this| {
|
||||
this.child(self.render_acp_import_onboarding(verbose, cx))
|
||||
})
|
||||
.when(show_cross_channel, |this| {
|
||||
this.child(self.render_cross_channel_import_onboarding(verbose, cx))
|
||||
})
|
||||
})
|
||||
.child(self.render_sidebar_bottom_bar(cx))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue