Detailed changes
@@ -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))
}