1431 lines · 50.2 KB · Rust
RawDownload
use std::time::Duration; use acp_thread::AgentSessionListRequest;use agent::ThreadStore;use agent_client_protocol::schema::v1 as acp;use chrono::Utc;use collections::{HashMap, HashSet};use db::kvp::Dismissable;use db::sqlez;use fs::Fs;use futures::FutureExt as _;use gpui::{    Animation, AnimationExt as _, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle,    Focusable, MouseDownEvent, Render, SharedString, Task, TaskExt, WeakEntity, Window,    pulsating_between,};use itertools::Itertools as _;use notifications::status_toast::StatusToast;use project::{AgentId, AgentRegistryStore, AgentServerStore};use release_channel::ReleaseChannel;use remote::RemoteConnectionOptions;use ui::{    Checkbox, CommonAnimationExt, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter,    ModalHeader, Section, Tooltip, prelude::*,};use util::ResultExt;use workspace::{ModalView, MultiWorkspace, Workspace}; use crate::{    Agent, AgentPanel,    agent_connection_store::AgentConnectionStore,    thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths},}; pub struct AcpThreadImportOnboarding;pub struct CrossChannelImportOnboarding; impl AcpThreadImportOnboarding {    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 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";} pub fn channels_with_threads(cx: &App) -> Task<Vec<SharedString>> {    let Some(current_channel) = ReleaseChannel::try_global(cx) else {        return Task::ready(Vec::new());    };    let database_dir = paths::database_dir();     let channel_has_threads = |database_dir: &std::path::Path, channel: ReleaseChannel| {        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)    };     cx.background_spawn(async move {        ReleaseChannel::ALL            .iter()            .copied()            .filter(|channel| {                *channel != current_channel                    && *channel != ReleaseChannel::Dev                    && channel_has_threads(database_dir, *channel)            })            .map(|channel| SharedString::new_static(channel.display_name()))            .collect()    })} #[derive(Clone)]struct AgentEntry {    agent_id: AgentId,    display_name: SharedString,    icon_path: Option<SharedString>,} #[derive(Clone)]enum AgentImportStatus {    Loading,    Ready { importable_count: usize },    Unsupported,    Error(SharedString),} impl AgentImportStatus {    fn is_selectable(&self) -> bool {        matches!(self, Self::Ready { importable_count } if *importable_count > 0)    }     fn tooltip_text(&self) -> Option<SharedString> {        match self {            Self::Loading => Some("Fetching Sessions…".into()),            Self::Ready { .. } => None,            Self::Unsupported => Some("Importing threads from this agent is not possible as it doesn't support ACP's session/list capability.".into()),            Self::Error(error) => Some(format!("Failed to fetch sessions: {error}").into()),        }    }} pub struct ThreadImportModal {    focus_handle: FocusHandle,    workspace: WeakEntity<Workspace>,    multi_workspace: WeakEntity<MultiWorkspace>,    agent_entries: Vec<AgentEntry>,    unchecked_agents: HashSet<AgentId>,    agent_import_statuses: HashMap<AgentId, AgentImportStatus>,    sessions_by_agent: Vec<SessionByAgent>,    selected_index: Option<usize>,    is_fetching_sessions: bool,    is_importing: bool,    last_error: Option<SharedString>,} impl ThreadImportModal {    pub fn new(        agent_server_store: Entity<AgentServerStore>,        agent_registry_store: Entity<AgentRegistryStore>,        workspace: WeakEntity<Workspace>,        multi_workspace: WeakEntity<MultiWorkspace>,        _window: &mut Window,        cx: &mut Context<Self>,    ) -> Self {        AcpThreadImportOnboarding::dismiss(cx);         let agent_entries = agent_server_store            .read(cx)            .external_agents()            .map(|agent_id| {                let display_name = agent_server_store                    .read(cx)                    .agent_display_name(agent_id)                    .or_else(|| {                        agent_registry_store                            .read(cx)                            .agent(agent_id)                            .map(|agent| agent.name().clone())                    })                    .unwrap_or_else(|| agent_id.0.clone());                let icon_path = agent_server_store                    .read(cx)                    .agent_icon(agent_id)                    .or_else(|| {                        agent_registry_store                            .read(cx)                            .agent(agent_id)                            .and_then(|agent| agent.icon_path().cloned())                    });                 AgentEntry {                    agent_id: agent_id.clone(),                    display_name,                    icon_path,                }            })            .sorted_unstable_by_key(|entry| entry.display_name.to_lowercase())            .collect::<Vec<_>>();         let this = Self {            focus_handle: cx.focus_handle(),            workspace,            multi_workspace,            agent_entries,            unchecked_agents: HashSet::default(),            agent_import_statuses: HashMap::default(),            sessions_by_agent: Vec::new(),            selected_index: None,            is_fetching_sessions: false,            is_importing: false,            last_error: None,        };        cx.spawn(async move |this, cx| this.update(cx, |this, cx| this.fetch_sessions(cx)))            .detach_and_log_err(cx);        this    }     fn agent_ids(&self) -> Vec<AgentId> {        self.agent_entries            .iter()            .map(|entry| entry.agent_id.clone())            .collect()    }     fn fetch_sessions(&mut self, cx: &mut Context<Self>) {        if self.agent_entries.is_empty() {            return;        }         let Some(multi_workspace) = self.multi_workspace.upgrade() else {            self.mark_all_agents_failed("Could not find workspace to import from.");            return;        };         let stores = resolve_agent_connection_stores(&multi_workspace, cx);        if stores.is_empty() {            log::error!("Did not find any workspaces to import from");            self.mark_all_agents_failed("Did not find any workspaces to import from.");            return;        }         self.is_fetching_sessions = true;        self.last_error = None;        self.sessions_by_agent.clear();        self.agent_import_statuses = self            .agent_ids()            .into_iter()            .map(|agent_id| (agent_id, AgentImportStatus::Loading))            .collect();         let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx)            .read(cx)            .entries()            .filter_map(|metadata| metadata.session_id.clone())            .collect();         for agent_id in self.agent_ids() {            let task =                fetch_sessions_for_agent(agent_id, existing_sessions.clone(), stores.clone(), cx);            cx.spawn(async move |this, cx| {                let result = task.await;                this.update(cx, |this, cx| {                    let AgentSessionFetchResult {                        agent_id,                        sessions_by_agent,                        status,                    } = result;                    this.sessions_by_agent                        .retain(|sessions| sessions.agent_id != agent_id);                    this.sessions_by_agent.extend(sessions_by_agent);                    this.agent_import_statuses.insert(agent_id, status);                    this.is_fetching_sessions = this.has_loading_agents();                    cx.notify();                })            })            .detach_and_log_err(cx);        }    }     fn mark_all_agents_failed(&mut self, message: impl Into<SharedString>) {        let message = message.into();        self.is_fetching_sessions = false;        self.sessions_by_agent.clear();        self.last_error = Some(message.clone());        self.agent_import_statuses = self            .agent_ids()            .into_iter()            .map(|agent_id| (agent_id, AgentImportStatus::Error(message.clone())))            .collect();    }     fn agent_is_selectable(&self, agent_id: &AgentId) -> bool {        self.agent_import_statuses            .get(agent_id)            .map_or(false, AgentImportStatus::is_selectable)    }     fn has_checked_selectable_agent(&self) -> bool {        self.agent_entries.iter().any(|entry| {            self.agent_is_selectable(&entry.agent_id)                && !self.unchecked_agents.contains(&entry.agent_id)        })    }     fn has_loading_agents(&self) -> bool {        self.agent_import_statuses            .values()            .any(|status| matches!(status, AgentImportStatus::Loading))    }     fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) {        if self.is_importing || !self.agent_is_selectable(&agent_id) {            return;        }         if self.unchecked_agents.contains(&agent_id) {            self.unchecked_agents.remove(&agent_id);        } else {            self.unchecked_agents.insert(agent_id);        }        cx.notify();    }     fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {        if self.agent_entries.is_empty() {            return;        }        self.selected_index = Some(match self.selected_index {            Some(ix) if ix + 1 >= self.agent_entries.len() => 0,            Some(ix) => ix + 1,            None => 0,        });        cx.notify();    }     fn select_previous(        &mut self,        _: &menu::SelectPrevious,        _window: &mut Window,        cx: &mut Context<Self>,    ) {        if self.agent_entries.is_empty() {            return;        }        self.selected_index = Some(match self.selected_index {            Some(0) => self.agent_entries.len() - 1,            Some(ix) => ix - 1,            None => self.agent_entries.len() - 1,        });        cx.notify();    }     fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {        if let Some(ix) = self.selected_index {            if let Some(entry) = self.agent_entries.get(ix) {                self.toggle_agent_checked(entry.agent_id.clone(), cx);            }        }    }     fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {        cx.emit(DismissEvent);    }     fn import_threads(        &mut self,        _: &menu::SecondaryConfirm,        _: &mut Window,        cx: &mut Context<Self>,    ) {        if self.is_importing || !self.has_checked_selectable_agent() {            return;        }         self.is_importing = true;        self.last_error = None;         let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx)            .read(cx)            .entries()            .filter_map(|metadata| metadata.session_id.clone())            .collect();         let selected_sessions_by_agent = self            .sessions_by_agent            .iter()            .filter(|sessions| {                self.agent_is_selectable(&sessions.agent_id)                    && !self.unchecked_agents.contains(&sessions.agent_id)            })            .cloned()            .collect::<Vec<_>>();        let threads = collect_importable_threads(selected_sessions_by_agent, existing_sessions);        let imported_count = threads.len();         ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_all(threads, cx));         self.is_importing = false;        self.show_imported_threads_toast(imported_count, cx);        cx.emit(DismissEvent);    }     fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {        let status_toast = if imported_count == 0 {            StatusToast::new("No threads found to import.", cx, |this, _cx| {                this.icon(                    Icon::new(IconName::Info)                        .size(IconSize::Small)                        .color(Color::Muted),                )                .dismiss_button(true)            })        } else {            let message = if imported_count == 1 {                "Imported 1 thread.".to_string()            } else {                format!("Imported {imported_count} threads.")            };            StatusToast::new(message, cx, |this, _cx| {                this.icon(                    Icon::new(IconName::Check)                        .size(IconSize::Small)                        .color(Color::Success),                )                .dismiss_button(true)            })        };         self.workspace            .update(cx, |workspace, cx| {                workspace.toggle_status_toast(status_toast, cx);            })            .log_err();    }} impl EventEmitter<DismissEvent> for ThreadImportModal {} impl Focusable for ThreadImportModal {    fn focus_handle(&self, _cx: &App) -> FocusHandle {        self.focus_handle.clone()    }} impl ModalView for ThreadImportModal {} impl Render for ThreadImportModal {    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {        let has_agents = !self.agent_entries.is_empty();        let disabled_import_thread =            self.is_importing || !has_agents || !self.has_checked_selectable_agent();         let agent_rows = self            .agent_entries            .iter()            .enumerate()            .map(|(ix, entry)| {                let status = self                    .agent_import_statuses                    .get(&entry.agent_id)                    .cloned()                    .unwrap_or(AgentImportStatus::Loading);                let is_selectable = status.is_selectable();                let is_checked = is_selectable && !self.unchecked_agents.contains(&entry.agent_id);                let is_focused = self.selected_index == Some(ix);                let row_disabled = self.is_importing || !is_selectable;                let checkbox_state = if is_checked {                    ToggleState::Selected                } else {                    ToggleState::Unselected                };                let end_slot = match &status {                    AgentImportStatus::Loading                    | AgentImportStatus::Unsupported                    | AgentImportStatus::Error(_) => {                        Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state)                            .disabled(true)                            .into_any_element()                    }                    AgentImportStatus::Ready { .. } => {                        Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state)                            .disabled(row_disabled)                            .into_any_element()                    }                };                 let is_loading = matches!(status, AgentImportStatus::Loading);                 let icon_color = if is_checked {                    Color::Muted                } else {                    Color::Disabled                };                 let item = h_flex()                    .w_full()                    .gap_2()                    .child(if let Some(icon_path) = entry.icon_path.clone() {                        Icon::from_external_svg(icon_path)                            .color(icon_color)                            .size(IconSize::Small)                    } else {                        Icon::new(IconName::Sparkle)                            .color(icon_color)                            .size(IconSize::Small)                    })                    .child(                        Label::new(entry.display_name.clone())                            .when(!is_checked, |s| s.color(Color::Disabled)),                    )                    .map(|this| match status {                        AgentImportStatus::Loading => this,                        AgentImportStatus::Ready {

Lines 1–500 of 1431.