From a03fb3791e0f8abfdae92a99538318786efe9814 Mon Sep 17 00:00:00 2001 From: Andy Waite Date: Fri, 11 Apr 2025 11:31:58 -0400 Subject: [PATCH 01/75] docs: Fix name for `zed: open project tasks` command (#28578) There's no `zed: open local tasks`, perhaps it was called that previously. Release Notes: - N/A --- docs/src/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 7eb6fd7f8d..557bedb118 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -65,7 +65,7 @@ Keep `"use_new_terminal": false` and set `"allow_concurrent_runs": true` to allo Tasks can be defined: - in the global `tasks.json` file; such tasks are available in all Zed projects you work on. This file is usually located in `~/.config/zed/tasks.json`. You can edit them by using the `zed: open tasks` action. -- in the worktree-specific (local) `.zed/tasks.json` file; such tasks are available only when working on a project with that worktree included. You can edit worktree-specific tasks by using the `zed: open local tasks` action. +- in the worktree-specific (local) `.zed/tasks.json` file; such tasks are available only when working on a project with that worktree included. You can edit worktree-specific tasks by using the `zed: open project tasks` action. - on the fly with [oneshot tasks](#oneshot-tasks). These tasks are project-specific and do not persist across sessions. - by language extension. From 2f5c662c4287cbde1a7414151aa9a9a835026900 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Fri, 11 Apr 2025 09:43:57 -0600 Subject: [PATCH 02/75] Refine component preview & add serialization (#28545) https://github.com/user-attachments/assets/0be12a9a-f6ce-4eca-90de-6ef01eb41ff9 - Allows the active ComponentPreview page to be restored via serialization - Allows filtering components using a filter input - Updates component example rendering - Updates some components Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- Cargo.lock | 6 + crates/component/src/component.rs | 42 +- crates/component_preview/Cargo.toml | 10 +- .../src/component_preview.rs | 387 +++++++++++++++--- crates/component_preview/src/persistence.rs | 73 ++++ crates/ui/src/components/avatar.rs | 72 +--- crates/ui/src/components/keybinding.rs | 2 +- 7 files changed, 468 insertions(+), 124 deletions(-) create mode 100644 crates/component_preview/src/persistence.rs diff --git a/Cargo.lock b/Cargo.lock index bfc32cbb24..4ce3131d66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3175,14 +3175,20 @@ dependencies = [ name = "component_preview" version = "0.1.0" dependencies = [ + "anyhow", "client", "collections", "component", + "db", + "futures 0.3.31", "gpui", "languages", "notifications", "project", + "serde", "ui", + "ui_input", + "util", "workspace", "workspace-hack", ] diff --git a/crates/component/src/component.rs b/crates/component/src/component.rs index 31ed169743..db847d5538 100644 --- a/crates/component/src/component.rs +++ b/crates/component/src/component.rs @@ -191,6 +191,14 @@ pub fn components() -> AllComponents { all_components } +// #[derive(Debug, Clone, PartialEq, Eq, Hash)] +// pub enum ComponentStatus { +// WorkInProgress, +// EngineeringReady, +// Live, +// Deprecated, +// } + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ComponentScope { Collaboration, @@ -241,24 +249,30 @@ pub struct ComponentExample { impl RenderOnce for ComponentExample { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { div() + .pt_2() .w_full() .flex() .flex_col() .gap_3() .child( div() - .child(self.variant_name.clone()) - .text_size(rems(1.25)) - .text_color(cx.theme().colors().text), + .flex() + .flex_col() + .child( + div() + .child(self.variant_name.clone()) + .text_size(rems(1.0)) + .text_color(cx.theme().colors().text), + ) + .when_some(self.description, |this, description| { + this.child( + div() + .text_size(rems(0.875)) + .text_color(cx.theme().colors().text_muted) + .child(description.clone()), + ) + }), ) - .when_some(self.description, |this, description| { - this.child( - div() - .text_size(rems(0.9375)) - .text_color(cx.theme().colors().text_muted) - .child(description.clone()), - ) - }) .child( div() .flex() @@ -268,11 +282,11 @@ impl RenderOnce for ComponentExample { .justify_center() .p_8() .border_1() - .border_color(cx.theme().colors().border) + .border_color(cx.theme().colors().border.opacity(0.5)) .bg(pattern_slash( cx.theme().colors().surface_background.opacity(0.5), - 24.0, - 24.0, + 12.0, + 12.0, )) .shadow_sm() .child(self.element), diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml index d8b4df34dd..e01e7d2208 100644 --- a/crates/component_preview/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -16,12 +16,16 @@ default = [] [dependencies] client.workspace = true +collections.workspace = true component.workspace = true gpui.workspace = true languages.workspace = true +notifications.workspace = true project.workspace = true ui.workspace = true -workspace.workspace = true -notifications.workspace = true -collections.workspace = true +ui_input.workspace = true workspace-hack.workspace = true +workspace.workspace = true +db.workspace = true +anyhow.workspace = true +serde.workspace = true diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 5109b8692d..276271828e 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -2,6 +2,8 @@ //! //! A view for exploring Zed components. +mod persistence; + use std::iter::Iterator; use std::sync::Arc; @@ -9,24 +11,27 @@ use client::UserStore; use component::{ComponentId, ComponentMetadata, components}; use gpui::{ App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*, - uniform_list, }; use collections::HashMap; -use gpui::{ListState, ScrollHandle, UniformListScrollHandle}; +use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle}; use languages::LanguageRegistry; use notifications::status_toast::{StatusToast, ToastIcon}; +use persistence::COMPONENT_PREVIEW_DB; use project::Project; -use ui::{Divider, ListItem, ListSubHeader, prelude::*}; +use ui::{Divider, HighlightedLabel, ListItem, ListSubHeader, prelude::*}; +use ui_input::SingleLineInput; use workspace::{AppState, ItemId, SerializableItem}; use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent}; pub fn init(app_state: Arc, cx: &mut App) { + workspace::register_serializable_item::(cx); + let app_state = app_state.clone(); - cx.observe_new(move |workspace: &mut Workspace, _, cx| { + cx.observe_new(move |workspace: &mut Workspace, _window, cx| { let app_state = app_state.clone(); let weak_workspace = cx.entity().downgrade(); @@ -44,6 +49,7 @@ pub fn init(app_state: Arc, cx: &mut App) { user_store, None, None, + window, cx, ) }); @@ -64,13 +70,13 @@ pub fn init(app_state: Arc, cx: &mut App) { enum PreviewEntry { AllComponents, Separator, - Component(ComponentMetadata), + Component(ComponentMetadata, Option>), SectionHeader(SharedString), } impl From for PreviewEntry { fn from(component: ComponentMetadata) -> Self { - PreviewEntry::Component(component) + PreviewEntry::Component(component, None) } } @@ -88,6 +94,7 @@ enum PreviewPage { } struct ComponentPreview { + workspace_id: Option, focus_handle: FocusHandle, _view_scroll_handle: ScrollHandle, nav_scroll_handle: UniformListScrollHandle, @@ -99,6 +106,8 @@ struct ComponentPreview { language_registry: Arc, workspace: WeakEntity, user_store: Entity, + filter_editor: Entity, + filter_text: String, } impl ComponentPreview { @@ -108,11 +117,14 @@ impl ComponentPreview { user_store: Entity, selected_index: impl Into>, active_page: Option, + window: &mut Window, cx: &mut Context, ) -> Self { let sorted_components = components().all_sorted(); let selected_index = selected_index.into().unwrap_or(0); let active_page = active_page.unwrap_or(PreviewPage::AllComponents); + let filter_editor = + cx.new(|cx| SingleLineInput::new(window, cx, "Find components or usages…")); let component_list = ListState::new( sorted_components.len(), @@ -132,6 +144,7 @@ impl ComponentPreview { ); let mut component_preview = Self { + workspace_id: None, focus_handle: cx.focus_handle(), _view_scroll_handle: ScrollHandle::new(), nav_scroll_handle: UniformListScrollHandle::new(), @@ -143,6 +156,8 @@ impl ComponentPreview { components: sorted_components, component_list, cursor_index: selected_index, + filter_editor, + filter_text: String::new(), }; if component_preview.cursor_index > 0 { @@ -154,6 +169,13 @@ impl ComponentPreview { component_preview } + pub fn active_page_id(&self, _cx: &App) -> ActivePageId { + match &self.active_page { + PreviewPage::AllComponents => ActivePageId::default(), + PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()), + } + } + fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context) { self.component_list.scroll_to_reveal_item(ix); self.cursor_index = ix; @@ -162,6 +184,7 @@ impl ComponentPreview { fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context) { self.active_page = page; + cx.emit(ItemEvent::UpdateTab); cx.notify(); } @@ -169,20 +192,94 @@ impl ComponentPreview { self.components[ix].clone() } + fn filtered_components(&self) -> Vec { + if self.filter_text.is_empty() { + return self.components.clone(); + } + + let filter = self.filter_text.to_lowercase(); + self.components + .iter() + .filter(|component| { + let component_name = component.name().to_lowercase(); + let scope_name = component.scope().to_string().to_lowercase(); + let description = component + .description() + .map(|d| d.to_lowercase()) + .unwrap_or_default(); + + component_name.contains(&filter) + || scope_name.contains(&filter) + || description.contains(&filter) + }) + .cloned() + .collect() + } + fn scope_ordered_entries(&self) -> Vec { use std::collections::HashMap; - let mut scope_groups: HashMap> = HashMap::default(); + let mut scope_groups: HashMap< + ComponentScope, + Vec<(ComponentMetadata, Option>)>, + > = HashMap::default(); + let lowercase_filter = self.filter_text.to_lowercase(); for component in &self.components { - scope_groups - .entry(component.scope()) - .or_insert_with(Vec::new) - .push(component.clone()); + if self.filter_text.is_empty() { + scope_groups + .entry(component.scope()) + .or_insert_with(Vec::new) + .push((component.clone(), None)); + continue; + } + + // let full_component_name = component.name(); + let scopeless_name = component.scopeless_name(); + let scope_name = component.scope().to_string(); + let description = component.description().unwrap_or_default(); + + let lowercase_scopeless = scopeless_name.to_lowercase(); + let lowercase_scope = scope_name.to_lowercase(); + let lowercase_desc = description.to_lowercase(); + + if lowercase_scopeless.contains(&lowercase_filter) { + if let Some(index) = lowercase_scopeless.find(&lowercase_filter) { + let end = index + lowercase_filter.len(); + + if end <= scopeless_name.len() { + let mut positions = Vec::new(); + for i in index..end { + if scopeless_name.is_char_boundary(i) { + positions.push(i); + } + } + + if !positions.is_empty() { + scope_groups + .entry(component.scope()) + .or_insert_with(Vec::new) + .push((component.clone(), Some(positions))); + continue; + } + } + } + } + + if lowercase_scopeless.contains(&lowercase_filter) + || lowercase_scope.contains(&lowercase_filter) + || lowercase_desc.contains(&lowercase_filter) + { + scope_groups + .entry(component.scope()) + .or_insert_with(Vec::new) + .push((component.clone(), None)); + } } + // Sort the components in each group for components in scope_groups.values_mut() { - components.sort_by_key(|c| c.name().to_lowercase()); + components.sort_by_key(|(c, _)| c.sort_name()); } let mut entries = Vec::new(); @@ -204,10 +301,10 @@ impl ComponentPreview { if !components.is_empty() { entries.push(PreviewEntry::SectionHeader(scope.to_string().into())); let mut sorted_components = components; - sorted_components.sort_by_key(|component| component.sort_name()); + sorted_components.sort_by_key(|(component, _)| component.sort_name()); - for component in sorted_components { - entries.push(PreviewEntry::Component(component)); + for (component, positions) in sorted_components { + entries.push(PreviewEntry::Component(component, positions)); } } } @@ -219,10 +316,10 @@ impl ComponentPreview { entries.push(PreviewEntry::Separator); entries.push(PreviewEntry::SectionHeader("Uncategorized".into())); let mut sorted_components = components.clone(); - sorted_components.sort_by_key(|c| c.sort_name()); + sorted_components.sort_by_key(|(c, _)| c.sort_name()); - for component in sorted_components { - entries.push(PreviewEntry::Component(component.clone())); + for (component, positions) in sorted_components { + entries.push(PreviewEntry::Component(component, positions)); } } } @@ -237,14 +334,33 @@ impl ComponentPreview { cx: &Context, ) -> impl IntoElement + use<> { match entry { - PreviewEntry::Component(component_metadata) => { + PreviewEntry::Component(component_metadata, highlight_positions) => { let id = component_metadata.id(); let selected = self.active_page == PreviewPage::Component(id.clone()); + let name = component_metadata.scopeless_name(); + ListItem::new(ix) - .child( - Label::new(component_metadata.scopeless_name().clone()) - .color(Color::Default), - ) + .child(if let Some(_positions) = highlight_positions { + let name_lower = name.to_lowercase(); + let filter_lower = self.filter_text.to_lowercase(); + let valid_positions = if let Some(start) = name_lower.find(&filter_lower) { + let end = start + filter_lower.len(); + (start..end).collect() + } else { + Vec::new() + }; + if valid_positions.is_empty() { + Label::new(name.clone()) + .color(Color::Default) + .into_any_element() + } else { + HighlightedLabel::new(name.clone(), valid_positions).into_any_element() + } + } else { + Label::new(name.clone()) + .color(Color::Default) + .into_any_element() + }) .selectable(true) .toggle_state(selected) .inset(true) @@ -282,20 +398,70 @@ impl ComponentPreview { } fn update_component_list(&mut self, cx: &mut Context) { - let new_len = self.scope_ordered_entries().len(); let entries = self.scope_ordered_entries(); + let new_len = entries.len(); let weak_entity = cx.entity().downgrade(); + if new_len > 0 { + self.nav_scroll_handle + .scroll_to_item(0, ScrollStrategy::Top); + } + + let filtered_components = self.filtered_components(); + + if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) { + if let PreviewPage::Component(ref component_id) = self.active_page { + let component_still_visible = filtered_components + .iter() + .any(|component| component.id() == *component_id); + + if !component_still_visible { + if !filtered_components.is_empty() { + let first_component = &filtered_components[0]; + self.set_active_page(PreviewPage::Component(first_component.id()), cx); + } else { + self.set_active_page(PreviewPage::AllComponents, cx); + } + } + } + } + + self.component_list = ListState::new( + filtered_components.len(), + gpui::ListAlignment::Top, + px(1500.0), + { + let components = filtered_components.clone(); + let this = cx.entity().downgrade(); + move |ix, window: &mut Window, cx: &mut App| { + if ix >= components.len() { + return div().w_full().h_0().into_any_element(); + } + + this.update(cx, |this, cx| { + let component = &components[ix]; + this.render_preview(component, window, cx) + .into_any_element() + }) + .unwrap() + } + }, + ); + let new_list = ListState::new( new_len, gpui::ListAlignment::Top, px(1500.0), move |ix, window, cx| { + if ix >= entries.len() { + return div().w_full().h_0().into_any_element(); + } + let entry = &entries[ix]; weak_entity .update(cx, |this, cx| match entry { - PreviewEntry::Component(component) => this + PreviewEntry::Component(component, _) => this .render_preview(component, window, cx) .into_any_element(), PreviewEntry::SectionHeader(shared_string) => this @@ -309,6 +475,7 @@ impl ComponentPreview { ); self.component_list = new_list; + cx.emit(ItemEvent::UpdateTab); } fn render_scope_header( @@ -377,16 +544,27 @@ impl ComponentPreview { .into_any_element() } - fn render_all_components(&self) -> impl IntoElement { + fn render_all_components(&self, cx: &Context) -> impl IntoElement { v_flex() .id("component-list") .px_8() .pt_4() .size_full() .child( - list(self.component_list.clone()) - .flex_grow() - .with_sizing_behavior(gpui::ListSizingBehavior::Auto), + if self.filtered_components().is_empty() && !self.filter_text.is_empty() { + div() + .size_full() + .items_center() + .justify_center() + .text_color(cx.theme().colors().text_muted) + .child(format!("No components matching '{}'.", self.filter_text)) + .into_any_element() + } else { + list(self.component_list.clone()) + .flex_grow() + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .into_any_element() + }, ) } @@ -432,6 +610,19 @@ impl ComponentPreview { impl Render for ComponentPreview { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // TODO: move this into the struct + let current_filter = self.filter_editor.update(cx, |input, cx| { + if input.is_empty(cx) { + String::new() + } else { + input.editor().read(cx).text(cx).to_string() + } + }); + + if current_filter != self.filter_text { + self.filter_text = current_filter; + self.update_component_list(cx); + } let sidebar_entries = self.scope_ordered_entries(); let active_page = self.active_page.clone(); @@ -449,14 +640,22 @@ impl Render for ComponentPreview { .border_color(cx.theme().colors().border) .h_full() .child( - uniform_list( + gpui::uniform_list( cx.entity().clone(), "component-nav", sidebar_entries.len(), move |this, range, _window, cx| { range - .map(|ix| { - this.render_sidebar_entry(ix, &sidebar_entries[ix], cx) + .filter_map(|ix| { + if ix < sidebar_entries.len() { + Some(this.render_sidebar_entry( + ix, + &sidebar_entries[ix], + cx, + )) + } else { + None + } }) .collect() }, @@ -481,12 +680,29 @@ impl Render for ComponentPreview { ), ), ) - .child(match active_page { - PreviewPage::AllComponents => self.render_all_components().into_any_element(), - PreviewPage::Component(id) => self - .render_component_page(&id, window, cx) - .into_any_element(), - }) + .child( + v_flex() + .id("content-area") + .flex_1() + .size_full() + .overflow_hidden() + .child( + div() + .p_2() + .w_full() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(self.filter_editor.clone()), + ) + .child(match active_page { + PreviewPage::AllComponents => { + self.render_all_components(cx).into_any_element() + } + PreviewPage::Component(id) => self + .render_component_page(&id, window, cx) + .into_any_element(), + }), + ) } } @@ -498,6 +714,21 @@ impl Focusable for ComponentPreview { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ActivePageId(pub String); + +impl Default for ActivePageId { + fn default() -> Self { + ActivePageId("AllComponents".to_string()) + } +} + +impl From for ActivePageId { + fn from(id: ComponentId) -> Self { + ActivePageId(id.0.to_string()) + } +} + impl Item for ComponentPreview { type Event = ItemEvent; @@ -516,7 +747,7 @@ impl Item for ComponentPreview { fn clone_on_split( &self, _workspace_id: Option, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Option> where @@ -535,6 +766,7 @@ impl Item for ComponentPreview { user_store, selected_index, Some(active_page), + window, cx, ) })) @@ -543,6 +775,15 @@ impl Item for ComponentPreview { fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { f(*event) } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + _window: &mut Window, + _cx: &mut Context, + ) { + self.workspace_id = workspace.database_id(); + } } impl SerializableItem for ComponentPreview { @@ -553,26 +794,53 @@ impl SerializableItem for ComponentPreview { fn deserialize( project: Entity, workspace: WeakEntity, - _workspace_id: WorkspaceId, - _item_id: ItemId, + workspace_id: WorkspaceId, + item_id: ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { + let deserialized_active_page = + match COMPONENT_PREVIEW_DB.get_active_page(item_id, workspace_id) { + Ok(page) => { + if let Some(page) = page { + ActivePageId(page) + } else { + ActivePageId::default() + } + } + Err(_) => ActivePageId::default(), + }; + let user_store = project.read(cx).user_store().clone(); let language_registry = project.read(cx).languages().clone(); + let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 { + Some(PreviewPage::default()) + } else { + let component_str = deserialized_active_page.0; + let component_registry = components(); + let all_components = component_registry.all(); + let found_component = all_components.iter().find(|c| c.id().0 == component_str); + + if let Some(component) = found_component { + Some(PreviewPage::Component(component.id().clone())) + } else { + Some(PreviewPage::default()) + } + }; window.spawn(cx, async move |cx| { let user_store = user_store.clone(); let language_registry = language_registry.clone(); let weak_workspace = workspace.clone(); - cx.update(|_, cx| { + cx.update(move |window, cx| { Ok(cx.new(|cx| { ComponentPreview::new( weak_workspace, language_registry, user_store, None, - None, + preview_page, + window, cx, ) })) @@ -581,34 +849,41 @@ impl SerializableItem for ComponentPreview { } fn cleanup( - _workspace_id: WorkspaceId, - _alive_items: Vec, + workspace_id: WorkspaceId, + alive_items: Vec, _window: &mut Window, - _cx: &mut App, + cx: &mut App, ) -> Task> { - Task::ready(Ok(())) - // window.spawn(cx, |_| { - // ... - // }) + cx.background_spawn(async move { + COMPONENT_PREVIEW_DB + .delete_unloaded_items(workspace_id, alive_items) + .await + }) } fn serialize( &mut self, _workspace: &mut Workspace, - _item_id: ItemId, + item_id: ItemId, _closing: bool, _window: &mut Window, - _cx: &mut Context, + cx: &mut Context, ) -> Option>> { - // TODO: Serialize the active index so we can re-open to the same place - None + let active_page = self.active_page_id(cx); + let workspace_id = self.workspace_id?; + Some(cx.background_spawn(async move { + COMPONENT_PREVIEW_DB + .save_active_page(item_id, workspace_id, active_page.0) + .await + })) } - fn should_serialize(&self, _event: &Self::Event) -> bool { - false + fn should_serialize(&self, event: &Self::Event) -> bool { + matches!(event, ItemEvent::UpdateTab) } } +// TODO: use language registry to allow rendering markdown #[derive(IntoElement)] pub struct ComponentPreviewPage { // languages: Arc, diff --git a/crates/component_preview/src/persistence.rs b/crates/component_preview/src/persistence.rs new file mode 100644 index 0000000000..a3fb0c698b --- /dev/null +++ b/crates/component_preview/src/persistence.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; +use workspace::{ItemId, WorkspaceDb, WorkspaceId}; + +define_connection! { + pub static ref COMPONENT_PREVIEW_DB: ComponentPreviewDb = + &[sql!( + CREATE TABLE component_previews ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + active_page_id TEXT, + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; +} + +impl ComponentPreviewDb { + pub async fn save_active_page( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + active_page_id: String, + ) -> Result<()> { + let query = "INSERT INTO component_previews(item_id, workspace_id, active_page_id) + VALUES (?1, ?2, ?3) + ON CONFLICT DO UPDATE SET + active_page_id = ?3"; + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&item_id, 1)?; + next_index = statement.bind(&workspace_id, next_index)?; + statement.bind(&active_page_id, next_index)?; + statement.exec() + }) + .await + } + + query! { + pub fn get_active_page(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + SELECT active_page_id + FROM component_previews + WHERE item_id = ? AND workspace_id = ? + } + } + + pub async fn delete_unloaded_items( + &self, + workspace: WorkspaceId, + alive_items: Vec, + ) -> Result<()> { + let placeholders = alive_items + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + + let query = format!( + "DELETE FROM component_previews WHERE workspace_id = ? AND item_id NOT IN ({placeholders})" + ); + + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&workspace, 1)?; + for id in alive_items { + next_index = statement.bind(&id, next_index)?; + } + statement.exec() + }) + .await + } +} diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index 668bdd5285..3ab31acc1b 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -236,53 +236,30 @@ impl Component for Avatar { v_flex() .gap_6() .children(vec![ + example_group(vec![ + single_example("Default", Avatar::new(example_avatar).into_any_element()), + single_example( + "Grayscale", + Avatar::new(example_avatar) + .grayscale(true) + .into_any_element(), + ), + single_example( + "Border", + Avatar::new(example_avatar) + .border_color(cx.theme().colors().border) + .into_any_element(), + ).description("Can be used to create visual space by setting the border color to match the background, which creates the appearance of a gap around the avatar."), + ]), example_group_with_title( - "Sizes", - vec![ - single_example( - "Default", - Avatar::new(example_avatar).into_any_element(), - ), - single_example( - "Small", - Avatar::new(example_avatar).size(px(24.)).into_any_element(), - ), - single_example( - "Large", - Avatar::new(example_avatar).size(px(48.)).into_any_element(), - ), - ], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Default", - Avatar::new(example_avatar).into_any_element(), - ), - single_example( - "Grayscale", - Avatar::new(example_avatar) - .grayscale(true) - .into_any_element(), - ), - single_example( - "With Border", - Avatar::new(example_avatar) - .border_color(cx.theme().colors().border) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Audio Status", + "Indicator Styles", vec![ single_example( "Muted", Avatar::new(example_avatar) .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted)) .into_any_element(), - ), + ).description("Indicates the collaborator's mic is muted."), single_example( "Deafened", Avatar::new(example_avatar) @@ -290,28 +267,23 @@ impl Component for Avatar { AudioStatus::Deafened, )) .into_any_element(), - ), - ], - ), - example_group_with_title( - "Availability", - vec![ + ).description("Indicates that both the collaborator's mic and audio are muted."), single_example( - "Free", + "Availability: Free", Avatar::new(example_avatar) .indicator(AvatarAvailabilityIndicator::new( CollaboratorAvailability::Free, )) .into_any_element(), - ), + ).description("Indicates that the person is free, usually meaning they are not in a call."), single_example( - "Busy", + "Availability: Busy", Avatar::new(example_avatar) .indicator(AvatarAvailabilityIndicator::new( CollaboratorAvailability::Busy, )) .into_any_element(), - ), + ).description("Indicates that the person is busy, usually meaning they are in a channel or direct call."), ], ), ]) diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index db9bb3008f..1b3746515d 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -451,7 +451,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode impl Component for KeyBinding { fn scope() -> ComponentScope { - ComponentScope::Input + ComponentScope::Typography } fn name() -> &'static str { From 1df01eabfe194f38012cb11979cb30ba3e0df3ad Mon Sep 17 00:00:00 2001 From: Thomas Jensen <49057994+th0jensen@users.noreply.github.com> Date: Fri, 11 Apr 2025 18:18:36 +0200 Subject: [PATCH 03/75] workspace: Implement Extended Terminal Option (#26211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #10211 Closes #7575 Screenshot of feature: ![Screenshot 2025-03-06 at 1 08 13 PM](https://github.com/user-attachments/assets/73cc4519-248b-4264-9ce8-42d0980cf73c) Screenshot of proposed menu: ![Screenshot 2025-03-06 at 1 14 30 PM](https://github.com/user-attachments/assets/efc7c18a-a2a5-491f-b3e5-5ed181f23906) Screenshot of proposed menu closed: ![Screenshot 2025-03-06 at 1 14 57 PM](https://github.com/user-attachments/assets/0b42829c-abe3-48aa-9b81-30a0aeeac8fd) Release Notes: - Configuration of bottom_dock_layout in settings.json - Layout Mode button in Title Bar - 4 different layout modes for the bottom dock: contained (default), full (extends below both docks), left-aligned, right-aligned (extends only below the respective dock) --------- Co-authored-by: Mikayla Maki --- assets/icons/layout.svg | 5 + assets/settings/default.json | 2 + crates/icons/src/icons.rs | 39 +-- crates/title_bar/src/title_bar.rs | 98 ++++++- crates/workspace/src/workspace.rs | 325 +++++++++++++++++---- crates/workspace/src/workspace_settings.rs | 19 ++ 6 files changed, 409 insertions(+), 79 deletions(-) create mode 100644 assets/icons/layout.svg diff --git a/assets/icons/layout.svg b/assets/icons/layout.svg new file mode 100644 index 0000000000..79464013b1 --- /dev/null +++ b/assets/icons/layout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 2a845fac0b..df1a4a01af 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -80,6 +80,8 @@ // Values are clamped to the [0.0, 1.0] range. "inactive_opacity": 1.0 }, + // Layout mode of the bottom dock. Defaults to "contained" + "bottom_dock_layout": "contained", // The direction that you want to split panes horizontally. Defaults to "up" "pane_split_direction_horizontal": "up", // The direction that you want to split panes horizontally. Defaults to "left" diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index aa8dcaf587..6c448c03ed 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -10,8 +10,8 @@ use strum::{EnumIter, EnumString, IntoStaticStr}; pub enum IconName { Ai, AiAnthropic, - AiBedrock, AiAnthropicHosted, + AiBedrock, AiDeepSeek, AiEdit, AiGoogle, @@ -61,6 +61,7 @@ pub enum IconName { CircleOff, Clipboard, Close, + Cloud, Code, Cog, Command, @@ -74,22 +75,22 @@ pub enum IconName { CountdownTimer, CursorIBeam, Dash, + DatabaseZap, + Debug, DebugBreakpoint, + DebugContinue, DebugDisabledBreakpoint, DebugDisabledLogBreakpoint, + DebugDisconnect, DebugIgnoreBreakpoints, + DebugLogBreakpoint, DebugPause, - DebugContinue, - DebugStepOver, + DebugRestart, + DebugStepBack, DebugStepInto, DebugStepOut, - DebugStepBack, - DebugRestart, - Debug, + DebugStepOver, DebugStop, - DebugDisconnect, - DebugLogBreakpoint, - DatabaseZap, Delete, Diff, Disconnected, @@ -99,18 +100,18 @@ pub enum IconName { Envelope, Eraser, Escape, - ExpandVertical, Exit, - ExternalLink, - ExpandUp, ExpandDown, + ExpandUp, + ExpandVertical, + ExternalLink, Eye, File, FileCode, FileCreate, FileDelete, - FileDoc, FileDiff, + FileDoc, FileGeneric, FileGit, FileLock, @@ -133,16 +134,17 @@ pub enum IconName { GenericMaximize, GenericMinimize, GenericRestore, - Github, - Globe, GitBranch, GitBranchSmall, + Github, + Globe, Hash, HistoryRerun, Indicator, Info, InlayHint, Keyboard, + Layout, Library, LightBulb, LineHeight, @@ -155,7 +157,6 @@ pub enum IconName { Maximize, Menu, MessageBubbles, - Cloud, Mic, MicMute, Microscope, @@ -227,8 +228,8 @@ pub enum IconName { Tab, Terminal, TextSnippet, - ThumbsUp, ThumbsDown, + ThumbsUp, Trash, TrashAlt, Triangle, @@ -247,10 +248,10 @@ pub enum IconName { ZedAssistant, ZedAssistantFilled, ZedPredict, - ZedPredictUp, - ZedPredictDown, ZedPredictDisabled, + ZedPredictDown, ZedPredictError, + ZedPredictUp, ZedXCopilot, } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index ed929cb1f3..225c3613ce 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -36,7 +36,7 @@ use ui::{ IconWithIndicator, Indicator, PopoverMenu, Tooltip, h_flex, prelude::*, }; use util::ResultExt; -use workspace::{Workspace, notifications::NotifyResultExt}; +use workspace::{BottomDockLayout, Workspace, notifications::NotifyResultExt}; use zed_actions::{OpenBrowser, OpenRecent, OpenRemote}; pub use onboarding_banner::restore_banner; @@ -210,6 +210,7 @@ impl Render for TitleBar { .pr_1() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .children(self.render_call_controls(window, cx)) + .child(self.render_bottom_dock_layout_menu(cx)) .map(|el| { let status = self.client.status(); let status = &*status.borrow(); @@ -622,6 +623,101 @@ impl TitleBar { } } + pub fn render_bottom_dock_layout_menu(&self, cx: &mut Context) -> impl IntoElement { + let workspace = self.workspace.upgrade().unwrap(); + let current_layout = workspace.update(cx, |workspace, _cx| workspace.bottom_dock_layout()); + + PopoverMenu::new("layout-menu") + .trigger( + IconButton::new("toggle_layout", IconName::Layout) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Layout Menu")), + ) + .anchor(gpui::Corner::TopRight) + .menu(move |window, cx| { + ContextMenu::build(window, cx, { + let workspace = workspace.clone(); + move |menu, _, _| { + menu.label("Bottom Dock") + .separator() + .toggleable_entry( + "Contained", + current_layout == BottomDockLayout::Contained, + ui::IconPosition::End, + None, + { + let workspace = workspace.clone(); + move |window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.set_bottom_dock_layout( + BottomDockLayout::Contained, + window, + cx, + ); + }); + } + }, + ) + .toggleable_entry( + "Full", + current_layout == BottomDockLayout::Full, + ui::IconPosition::End, + None, + { + let workspace = workspace.clone(); + move |window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.set_bottom_dock_layout( + BottomDockLayout::Full, + window, + cx, + ); + }); + } + }, + ) + .toggleable_entry( + "Left Aligned", + current_layout == BottomDockLayout::LeftAligned, + ui::IconPosition::End, + None, + { + let workspace = workspace.clone(); + move |window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.set_bottom_dock_layout( + BottomDockLayout::LeftAligned, + window, + cx, + ); + }); + } + }, + ) + .toggleable_entry( + "Right Aligned", + current_layout == BottomDockLayout::RightAligned, + ui::IconPosition::End, + None, + { + let workspace = workspace.clone(); + move |window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.set_bottom_dock_layout( + BottomDockLayout::RightAligned, + window, + cx, + ); + }); + } + }, + ) + } + }) + .into() + }) + } + pub fn render_sign_in_button(&mut self, _: &mut Context) -> Button { let client = self.client.clone(); Button::new("sign_in", "Sign in") diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5a9dce7c01..01d836f48d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -102,7 +102,7 @@ use ui::prelude::*; use util::{ResultExt, TryFutureExt, paths::SanitizedPath, serde::default_true}; use uuid::Uuid; pub use workspace_settings::{ - AutosaveSetting, RestoreOnStartupBehavior, TabBarSettings, WorkspaceSettings, + AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, TabBarSettings, WorkspaceSettings, }; use crate::notifications::NotificationId; @@ -819,6 +819,7 @@ pub struct Workspace { center: PaneGroup, left_dock: Entity, bottom_dock: Entity, + bottom_dock_layout: BottomDockLayout, right_dock: Entity, panes: Vec>, panes_by_item: HashMap>, @@ -1044,6 +1045,7 @@ impl Workspace { let modal_layer = cx.new(|_| ModalLayer::new()); let toast_layer = cx.new(|_| ToastLayer::new()); + let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout; let left_dock = Dock::new(DockPosition::Left, modal_layer.clone(), window, cx); let bottom_dock = Dock::new(DockPosition::Bottom, modal_layer.clone(), window, cx); let right_dock = Dock::new(DockPosition::Right, modal_layer.clone(), window, cx); @@ -1141,6 +1143,7 @@ impl Workspace { notifications: Default::default(), left_dock, bottom_dock, + bottom_dock_layout, right_dock, project: project.clone(), follower_states: Default::default(), @@ -1349,6 +1352,26 @@ impl Workspace { &self.bottom_dock } + pub fn bottom_dock_layout(&self) -> BottomDockLayout { + self.bottom_dock_layout + } + + pub fn set_bottom_dock_layout( + &mut self, + layout: BottomDockLayout, + window: &mut Window, + cx: &mut Context, + ) { + let fs = self.project().read(cx).fs(); + settings::update_settings_file::(fs.clone(), cx, move |content, _cx| { + content.bottom_dock_layout = Some(layout); + }); + + self.bottom_dock_layout = layout; + cx.notify(); + self.serialize_workspace(window, cx); + } + pub fn right_dock(&self) -> &Entity { &self.right_dock } @@ -5535,64 +5558,248 @@ impl Render for Workspace { }, )) }) - .child( - div() - .flex() - .flex_row() - .h_full() - // Left Dock - .children(self.render_dock( - DockPosition::Left, - &self.left_dock, - window, - cx, - )) - // Panes - .child( - div() - .flex() - .flex_col() - .flex_1() - .overflow_hidden() - .child( - h_flex() - .flex_1() - .when_some(paddings.0, |this, p| { - this.child(p.border_r_1()) - }) - .child(self.center.render( - self.zoomed.as_ref(), - &PaneRenderContext { - follower_states: - &self.follower_states, - active_call: self.active_call(), - active_pane: &self.active_pane, - app_state: &self.app_state, - project: &self.project, - workspace: &self.weak_self, - }, - window, - cx, - )) - .when_some(paddings.1, |this, p| { - this.child(p.border_l_1()) - }), - ) - .children(self.render_dock( - DockPosition::Bottom, - &self.bottom_dock, - window, - cx, - )), - ) - // Right Dock - .children(self.render_dock( - DockPosition::Right, - &self.right_dock, - window, - cx, - )), - ) + .child({ + match self.bottom_dock_layout { + BottomDockLayout::Full => div() + .flex() + .flex_col() + .h_full() + .child( + div() + .flex() + .flex_row() + .flex_1() + .overflow_hidden() + .children(self.render_dock( + DockPosition::Left, + &self.left_dock, + window, + cx, + )) + .child( + div() + .flex() + .flex_col() + .flex_1() + .overflow_hidden() + .child( + h_flex() + .flex_1() + .when_some( + paddings.0, + |this, p| { + this.child( + p.border_r_1(), + ) + }, + ) + .child(self.center.render( + self.zoomed.as_ref(), + &PaneRenderContext { + follower_states: + &self.follower_states, + active_call: self.active_call(), + active_pane: &self.active_pane, + app_state: &self.app_state, + project: &self.project, + workspace: &self.weak_self, + }, + window, + cx, + )) + .when_some( + paddings.1, + |this, p| { + this.child( + p.border_l_1(), + ) + }, + ), + ), + ) + .children(self.render_dock( + DockPosition::Right, + &self.right_dock, + window, + cx, + )), + ) + .child(div().w_full().children(self.render_dock( + DockPosition::Bottom, + &self.bottom_dock, + window, + cx + ))), + + BottomDockLayout::LeftAligned => div() + .flex() + .flex_row() + .h_full() + .child( + div() + .flex() + .flex_col() + .flex_1() + .h_full() + .child( + div() + .flex() + .flex_row() + .flex_1() + .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .overflow_hidden() + .child( + h_flex() + .flex_1() + .when_some(paddings.0, |this, p| this.child(p.border_r_1())) + .child(self.center.render( + self.zoomed.as_ref(), + &PaneRenderContext { + follower_states: + &self.follower_states, + active_call: self.active_call(), + active_pane: &self.active_pane, + app_state: &self.app_state, + project: &self.project, + workspace: &self.weak_self, + }, + window, + cx, + )) + .when_some(paddings.1, |this, p| this.child(p.border_l_1())), + ) + ) + ) + .child( + div() + .w_full() + .children(self.render_dock(DockPosition::Bottom, &self.bottom_dock, window, cx)) + ), + ) + .children(self.render_dock( + DockPosition::Right, + &self.right_dock, + window, + cx, + )), + + BottomDockLayout::RightAligned => div() + .flex() + .flex_row() + .h_full() + .children(self.render_dock( + DockPosition::Left, + &self.left_dock, + window, + cx, + )) + .child( + div() + .flex() + .flex_col() + .flex_1() + .h_full() + .child( + div() + .flex() + .flex_row() + .flex_1() + .child( + div() + .flex() + .flex_col() + .flex_1() + .overflow_hidden() + .child( + h_flex() + .flex_1() + .when_some(paddings.0, |this, p| this.child(p.border_r_1())) + .child(self.center.render( + self.zoomed.as_ref(), + &PaneRenderContext { + follower_states: + &self.follower_states, + active_call: self.active_call(), + active_pane: &self.active_pane, + app_state: &self.app_state, + project: &self.project, + workspace: &self.weak_self, + }, + window, + cx, + )) + .when_some(paddings.1, |this, p| this.child(p.border_l_1())), + ) + ) + .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) + ) + .child( + div() + .w_full() + .children(self.render_dock(DockPosition::Bottom, &self.bottom_dock, window, cx)) + ), + ), + + BottomDockLayout::Contained => div() + .flex() + .flex_row() + .h_full() + .children(self.render_dock( + DockPosition::Left, + &self.left_dock, + window, + cx, + )) + .child( + div() + .flex() + .flex_col() + .flex_1() + .overflow_hidden() + .child( + h_flex() + .flex_1() + .when_some(paddings.0, |this, p| { + this.child(p.border_r_1()) + }) + .child(self.center.render( + self.zoomed.as_ref(), + &PaneRenderContext { + follower_states: + &self.follower_states, + active_call: self.active_call(), + active_pane: &self.active_pane, + app_state: &self.app_state, + project: &self.project, + workspace: &self.weak_self, + }, + window, + cx, + )) + .when_some(paddings.1, |this, p| { + this.child(p.border_l_1()) + }), + ) + .children(self.render_dock( + DockPosition::Bottom, + &self.bottom_dock, + window, + cx, + )), + ) + .children(self.render_dock( + DockPosition::Right, + &self.right_dock, + window, + cx, + )), + } + }) .children(self.zoomed.as_ref().and_then(|view| { let zoomed_view = view.upgrade()?; let div = div() diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 2dda042288..a61a987b1c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -10,6 +10,7 @@ use settings::{Settings, SettingsSources}; #[derive(Deserialize)] pub struct WorkspaceSettings { pub active_pane_modifiers: ActivePanelModifiers, + pub bottom_dock_layout: BottomDockLayout, pub pane_split_direction_horizontal: PaneSplitDirectionHorizontal, pub pane_split_direction_vertical: PaneSplitDirectionVertical, pub centered_layout: CenteredLayoutSettings, @@ -71,6 +72,20 @@ pub struct ActivePanelModifiers { pub inactive_opacity: Option, } +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum BottomDockLayout { + /// Contained between the left and right docks + #[default] + Contained, + /// Takes up the full width of the window + Full, + /// Extends under the left dock while snapping to the right dock + LeftAligned, + /// Extends under the right dock while snapping to the left dock + RightAligned, +} + #[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum CloseWindowWhenNoItems { @@ -109,6 +124,10 @@ pub enum RestoreOnStartupBehavior { pub struct WorkspaceSettingsContent { /// Active pane styling settings. pub active_pane_modifiers: Option, + /// Layout mode for the bottom dock + /// + /// Default: contained + pub bottom_dock_layout: Option, /// Direction to split horizontally. /// /// Default: "up" From 08ce230bae570088862c3f413eed0020be6a4ec5 Mon Sep 17 00:00:00 2001 From: Peter Finn Date: Fri, 11 Apr 2025 10:12:30 -0700 Subject: [PATCH 04/75] vim: Add some forced motion support (#27991) Closes https://github.com/zed-industries/zed/issues/20971 Added `v` input to yank and delete to override default motion. The global vim state tracking if the forced motion flag was passed handled the same way that the count is. [The main chunk of code maps the motion kind from the default to the overridden kind](https://github.com/zed-industries/zed/pull/27991/files#diff-2dca6b7d1673c912d14e4edc74e415abbe3a4e6d6b37e0e2006d30828bf4bb9cR1249-R1254). To handle the case of deleting a single character (dv0) at the start of a row I had to modify the control flow [here](https://github.com/zed-industries/zed/pull/27991/files#diff-2dca6b7d1673c912d14e4edc74e415abbe3a4e6d6b37e0e2006d30828bf4bb9cR1240-R1244). Then to handle an exclusive delete till the end of the row (dv$) I [saturated the endpoint with a left bias](https://github.com/zed-industries/zed/pull/27991/files#diff-2dca6b7d1673c912d14e4edc74e415abbe3a4e6d6b37e0e2006d30828bf4bb9cR1281-R1286). Test case: dv0 https://github.com/user-attachments/assets/613cf9fb-9732-425c-9179-025f3e107584 Test case: yvjp https://github.com/user-attachments/assets/550b7c77-1eb8-41c3-894b-117eb50b7a5d Release Notes: - Added some forced motion support for delete and yank --- assets/keymaps/vim.json | 2 + crates/vim/src/change_list.rs | 1 + crates/vim/src/command.rs | 10 +- crates/vim/src/indent.rs | 12 +- crates/vim/src/insert.rs | 1 + crates/vim/src/motion.rs | 190 +++++++++++++++++- crates/vim/src/normal.rs | 131 ++++++++---- crates/vim/src/normal/change.rs | 4 +- crates/vim/src/normal/convert.rs | 10 +- crates/vim/src/normal/delete.rs | 11 +- crates/vim/src/normal/increment.rs | 2 + crates/vim/src/normal/paste.rs | 10 +- crates/vim/src/normal/repeat.rs | 2 + crates/vim/src/normal/scroll.rs | 1 + crates/vim/src/normal/search.rs | 3 + crates/vim/src/normal/substitute.rs | 4 + crates/vim/src/normal/toggle_comments.rs | 9 +- crates/vim/src/normal/yank.rs | 16 +- crates/vim/src/replace.rs | 10 +- crates/vim/src/rewrap.rs | 10 +- crates/vim/src/state.rs | 2 +- crates/vim/src/surrounds.rs | 9 +- .../src/test/neovim_backed_test_context.rs | 2 +- crates/vim/src/test/vim_test_context.rs | 4 + crates/vim/src/vim.rs | 18 +- crates/vim/src/visual.rs | 5 + ...t_forced_motion_delete_to_end_of_line.json | 10 + ...forced_motion_delete_to_start_of_line.json | 15 ++ .../test_data/test_forced_motion_yank.json | 24 +++ .../test_inclusive_to_exclusive_delete.json | 15 ++ 30 files changed, 485 insertions(+), 58 deletions(-) create mode 100644 crates/vim/test_data/test_forced_motion_delete_to_end_of_line.json create mode 100644 crates/vim/test_data/test_forced_motion_delete_to_start_of_line.json create mode 100644 crates/vim/test_data/test_forced_motion_yank.json create mode 100644 crates/vim/test_data/test_inclusive_to_exclusive_delete.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 40c96ed5d8..452731ebc2 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -539,6 +539,7 @@ "bindings": { "d": "vim::CurrentLine", "s": "vim::PushDeleteSurrounds", + "v": "vim::PushForcedMotion", // "d v" "o": "editor::ToggleSelectedDiffHunks", // "d o" "shift-o": "git::ToggleStaged", "p": "git::Restore", // "d p" @@ -587,6 +588,7 @@ "context": "vim_operator == y", "bindings": { "y": "vim::CurrentLine", + "v": "vim::PushForcedMotion", "s": ["vim::PushAddSurrounds", {}] } }, diff --git a/crates/vim/src/change_list.rs b/crates/vim/src/change_list.rs index baa72dbc1f..0d0a5898b2 100644 --- a/crates/vim/src/change_list.rs +++ b/crates/vim/src/change_list.rs @@ -24,6 +24,7 @@ impl Vim { cx: &mut Context, ) { let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); if self.change_list.is_empty() { return; } diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 9245701bf3..15f008f91d 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -234,6 +234,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { return; }; let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); let n = if count > 1 { format!(".,.+{}", count.saturating_sub(1)) } else { @@ -1323,6 +1324,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -1335,7 +1337,13 @@ impl Vim { let start = editor.selections.newest_display(cx); let text_layout_details = editor.text_layout_details(window); let (mut range, _) = motion - .range(&snapshot, start.clone(), times, &text_layout_details) + .range( + &snapshot, + start.clone(), + times, + &text_layout_details, + forced_motion, + ) .unwrap_or((start.range(), MotionKind::Exclusive)); if range.start != start.start { editor.change_selections(None, window, cx, |s| { diff --git a/crates/vim/src/indent.rs b/crates/vim/src/indent.rs index 3f0ed5251f..ac708a7e89 100644 --- a/crates/vim/src/indent.rs +++ b/crates/vim/src/indent.rs @@ -18,6 +18,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Indent, window, cx| { vim.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); vim.update_editor(window, cx, |vim, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -36,6 +37,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Outdent, window, cx| { vim.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); vim.update_editor(window, cx, |vim, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -54,6 +56,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &AutoIndent, window, cx| { vim.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); vim.update_editor(window, cx, |vim, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -75,6 +78,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, dir: IndentDirection, window: &mut Window, cx: &mut Context, @@ -88,7 +92,13 @@ impl Vim { s.move_with(|map, selection| { let anchor = map.display_point_to_anchor(selection.head(), Bias::Right); selection_starts.insert(selection.id, anchor); - motion.expand_selection(map, selection, times, &text_layout_details); + motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); }); }); match dir { diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 550d5b57fd..561ceec0a8 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -23,6 +23,7 @@ impl Vim { return; } let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); self.stop_recording_immediately(action.boxed_clone(), cx); if count <= 1 || Vim::globals(cx).dot_replaying { self.create_mark("^".into(), window, cx); diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index bbe1ef01a6..fcf1e07749 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -650,6 +650,7 @@ impl Vim { } let count = Vim::take_count(cx); + let forced_motion = Vim::take_forced_motion(cx); let active_operator = self.active_operator(); let mut waiting_operator: Option = None; match self.mode { @@ -659,7 +660,14 @@ impl Vim { target: Some(SurroundsType::Motion(motion)), }); } else { - self.normal_motion(motion.clone(), active_operator.clone(), count, window, cx) + self.normal_motion( + motion.clone(), + active_operator.clone(), + count, + forced_motion, + window, + cx, + ) } } Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { @@ -1183,7 +1191,6 @@ impl Motion { SelectionGoal::None, ), }; - (new_point != point || infallible).then_some((new_point, goal)) } @@ -1194,6 +1201,7 @@ impl Motion { selection: Selection, times: Option, text_layout_details: &TextLayoutDetails, + forced_motion: bool, ) -> Option<(Range, MotionKind)> { if let Motion::ZedSearchResult { prior_selections, @@ -1221,18 +1229,29 @@ impl Motion { return None; } } - - let (new_head, goal) = self.move_point( + let maybe_new_point = self.move_point( map, selection.head(), selection.goal, times, text_layout_details, - )?; + ); + + let (new_head, goal) = match (maybe_new_point, forced_motion) { + (Some((p, g)), _) => Some((p, g)), + (None, false) => None, + (None, true) => Some((selection.head(), selection.goal)), + }?; + let mut selection = selection.clone(); selection.set_head(new_head, goal); - let mut kind = self.default_kind(); + let mut kind = match (self.default_kind(), forced_motion) { + (MotionKind::Linewise, true) => MotionKind::Exclusive, + (MotionKind::Exclusive, true) => MotionKind::Inclusive, + (MotionKind::Inclusive, true) => MotionKind::Exclusive, + (kind, false) => kind, + }; if let Motion::NextWordStart { ignore_punctuation: _, @@ -1259,6 +1278,12 @@ impl Motion { } else if kind == MotionKind::Exclusive && !self.skip_exclusive_special_case() { let start_point = selection.start.to_point(map); let mut end_point = selection.end.to_point(map); + let mut next_point = selection.end; + *next_point.column_mut() += 1; + next_point = map.clip_point(next_point, Bias::Right); + if next_point.to_point(map) == end_point && forced_motion { + selection.end = movement::saturating_left(map, selection.end); + } if end_point.row > start_point.row { let first_non_blank_of_start_row = map @@ -1304,8 +1329,15 @@ impl Motion { selection: &mut Selection, times: Option, text_layout_details: &TextLayoutDetails, + forced_motion: bool, ) -> Option { - let (range, kind) = self.range(map, selection.clone(), times, text_layout_details)?; + let (range, kind) = self.range( + map, + selection.clone(), + times, + text_layout_details, + forced_motion, + )?; selection.start = range.start; selection.end = range.end; Some(kind) @@ -3816,6 +3848,7 @@ mod test { Mode::Normal, ); } + #[gpui::test] async fn test_delete_key_can_remove_last_character(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; @@ -3823,4 +3856,147 @@ mod test { cx.simulate_shared_keystrokes("delete").await; cx.shared_state().await.assert_eq("aˇb"); } + + #[gpui::test] + async fn test_forced_motion_delete_to_start_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇthe quick brown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v 0").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇhe quick brown fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick bˇrown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v 0").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇown fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick brown foˇx + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v 0").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇ + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + } + + #[gpui::test] + async fn test_forced_motion_delete_to_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + the quick brown foˇx + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v $").await; + cx.shared_state().await.assert_eq(indoc! {" + the quick brown foˇx + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + ˇthe quick brown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v $").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇx + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + } + + #[gpui::test] + async fn test_forced_motion_yank(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇthe quick brown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("y v j p").await; + cx.shared_state().await.assert_eq(indoc! {" + the quick brown fox + ˇthe quick brown fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick bˇrown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("y v j p").await; + cx.shared_state().await.assert_eq(indoc! {" + the quick brˇrown fox + jumped overown fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick brown foˇx + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("y v j p").await; + cx.shared_state().await.assert_eq(indoc! {" + the quick brown foxˇx + jumped over the la + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick brown fox + jˇumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("y v k p").await; + cx.shared_state().await.assert_eq(indoc! {" + thˇhe quick brown fox + je quick brown fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + } + + #[gpui::test] + async fn test_inclusive_to_exclusive_delete(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇthe quick brown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v e").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇe quick brown fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick bˇrown fox + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v e").await; + cx.shared_state().await.assert_eq(indoc! {" + the quick bˇn fox + jumped over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + + cx.set_shared_state(indoc! {" + the quick brown foˇx + jumped over the lazy dog"}) + .await; + cx.simulate_shared_keystrokes("d v e").await; + cx.shared_state().await.assert_eq(indoc! {" + the quick brown foˇd over the lazy dog"}); + assert_eq!(cx.cx.forced_motion(), false); + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 43657ffd73..7781891050 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -86,12 +86,14 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &DeleteLeft, window, cx| { vim.record_current_action(cx); let times = Vim::take_count(cx); - vim.delete_motion(Motion::Left, times, window, cx); + let forced_motion = Vim::take_forced_motion(cx); + vim.delete_motion(Motion::Left, times, forced_motion, window, cx); }); Vim::action(editor, cx, |vim, _: &DeleteRight, window, cx| { vim.record_current_action(cx); let times = Vim::take_count(cx); - vim.delete_motion(Motion::Right, times, window, cx); + let forced_motion = Vim::take_forced_motion(cx); + vim.delete_motion(Motion::Right, times, forced_motion, window, cx); }); Vim::action(editor, cx, |vim, _: &HelixDelete, window, cx| { @@ -111,11 +113,13 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &ChangeToEndOfLine, window, cx| { vim.start_recording(cx); let times = Vim::take_count(cx); + let forced_motion = Vim::take_forced_motion(cx); vim.change_motion( Motion::EndOfLine { display_lines: false, }, times, + forced_motion, window, cx, ); @@ -123,11 +127,13 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &DeleteToEndOfLine, window, cx| { vim.record_current_action(cx); let times = Vim::take_count(cx); + let forced_motion = Vim::take_forced_motion(cx); vim.delete_motion( Motion::EndOfLine { display_lines: false, }, times, + forced_motion, window, cx, ); @@ -142,6 +148,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Undo, window, cx| { let times = Vim::take_count(cx); + Vim::take_forced_motion(cx); vim.update_editor(window, cx, |_, editor, window, cx| { for _ in 0..times.unwrap_or(1) { editor.undo(&editor::actions::Undo, window, cx); @@ -150,6 +157,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, _: &Redo, window, cx| { let times = Vim::take_count(cx); + Vim::take_forced_motion(cx); vim.update_editor(window, cx, |_, editor, window, cx| { for _ in 0..times.unwrap_or(1) { editor.redo(&editor::actions::Redo, window, cx); @@ -170,48 +178,93 @@ impl Vim { motion: Motion, operator: Option, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { match operator { None => self.move_cursor(motion, times, window, cx), - Some(Operator::Change) => self.change_motion(motion, times, window, cx), - Some(Operator::Delete) => self.delete_motion(motion, times, window, cx), - Some(Operator::Yank) => self.yank_motion(motion, times, window, cx), + Some(Operator::Change) => self.change_motion(motion, times, forced_motion, window, cx), + Some(Operator::Delete) => self.delete_motion(motion, times, forced_motion, window, cx), + Some(Operator::Yank) => self.yank_motion(motion, times, forced_motion, window, cx), Some(Operator::AddSurrounds { target: None }) => {} - Some(Operator::Indent) => { - self.indent_motion(motion, times, IndentDirection::In, window, cx) - } - Some(Operator::Rewrap) => self.rewrap_motion(motion, times, window, cx), - Some(Operator::Outdent) => { - self.indent_motion(motion, times, IndentDirection::Out, window, cx) - } - Some(Operator::AutoIndent) => { - self.indent_motion(motion, times, IndentDirection::Auto, window, cx) - } - Some(Operator::ShellCommand) => self.shell_command_motion(motion, times, window, cx), - Some(Operator::Lowercase) => { - self.convert_motion(motion, times, ConvertTarget::LowerCase, window, cx) - } - Some(Operator::Uppercase) => { - self.convert_motion(motion, times, ConvertTarget::UpperCase, window, cx) - } - Some(Operator::OppositeCase) => { - self.convert_motion(motion, times, ConvertTarget::OppositeCase, window, cx) - } - Some(Operator::Rot13) => { - self.convert_motion(motion, times, ConvertTarget::Rot13, window, cx) - } - Some(Operator::Rot47) => { - self.convert_motion(motion, times, ConvertTarget::Rot47, window, cx) + Some(Operator::Indent) => self.indent_motion( + motion, + times, + forced_motion, + IndentDirection::In, + window, + cx, + ), + Some(Operator::Rewrap) => self.rewrap_motion(motion, times, forced_motion, window, cx), + Some(Operator::Outdent) => self.indent_motion( + motion, + times, + forced_motion, + IndentDirection::Out, + window, + cx, + ), + Some(Operator::AutoIndent) => self.indent_motion( + motion, + times, + forced_motion, + IndentDirection::Auto, + window, + cx, + ), + Some(Operator::ShellCommand) => { + self.shell_command_motion(motion, times, forced_motion, window, cx) } + Some(Operator::Lowercase) => self.convert_motion( + motion, + times, + forced_motion, + ConvertTarget::LowerCase, + window, + cx, + ), + Some(Operator::Uppercase) => self.convert_motion( + motion, + times, + forced_motion, + ConvertTarget::UpperCase, + window, + cx, + ), + Some(Operator::OppositeCase) => self.convert_motion( + motion, + times, + forced_motion, + ConvertTarget::OppositeCase, + window, + cx, + ), + Some(Operator::Rot13) => self.convert_motion( + motion, + times, + forced_motion, + ConvertTarget::Rot13, + window, + cx, + ), + Some(Operator::Rot47) => self.convert_motion( + motion, + times, + forced_motion, + ConvertTarget::Rot47, + window, + cx, + ), Some(Operator::ToggleComments) => { - self.toggle_comments_motion(motion, times, window, cx) + self.toggle_comments_motion(motion, times, forced_motion, window, cx) } Some(Operator::ReplaceWithRegister) => { - self.replace_with_register_motion(motion, times, window, cx) + self.replace_with_register_motion(motion, times, forced_motion, window, cx) + } + Some(Operator::Exchange) => { + self.exchange_motion(motion, times, forced_motion, window, cx) } - Some(Operator::Exchange) => self.exchange_motion(motion, times, window, cx), Some(operator) => { // Can't do anything for text objects, Ignoring error!("Unexpected normal mode motion operator: {:?}", operator) @@ -492,6 +545,7 @@ impl Vim { ) { self.record_current_action(cx); let mut times = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); if self.mode.is_visual() { times = 1; } else if times > 1 { @@ -513,11 +567,19 @@ impl Vim { fn yank_line(&mut self, _: &YankLine, window: &mut Window, cx: &mut Context) { let count = Vim::take_count(cx); - self.yank_motion(motion::Motion::CurrentLine, count, window, cx) + let forced_motion = Vim::take_forced_motion(cx); + self.yank_motion( + motion::Motion::CurrentLine, + count, + forced_motion, + window, + cx, + ) } fn show_location(&mut self, _: &ShowLocation, window: &mut Window, cx: &mut Context) { let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); self.update_editor(window, cx, |vim, editor, _window, cx| { let selection = editor.selections.newest_anchor(); if let Some((_, buffer, _)) = editor.active_excerpt(cx) { @@ -577,6 +639,7 @@ impl Vim { cx: &mut Context, ) { let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); self.stop_recording(cx); self.update_editor(window, cx, |_, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 199ac8b0c7..7e27cda949 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -18,6 +18,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -59,6 +60,7 @@ impl Vim { selection, times, &text_layout_details, + forced_motion, ); if let Motion::CurrentLine = motion { let mut start_offset = @@ -181,7 +183,7 @@ fn expand_changed_word_selection( } else { Motion::NextWordStart { ignore_punctuation } }; - motion.expand_selection(map, selection, times, text_layout_details) + motion.expand_selection(map, selection, times, text_layout_details, false) } } diff --git a/crates/vim/src/normal/convert.rs b/crates/vim/src/normal/convert.rs index af0154d3c2..31aac771c2 100644 --- a/crates/vim/src/normal/convert.rs +++ b/crates/vim/src/normal/convert.rs @@ -25,6 +25,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, mode: ConvertTarget, window: &mut Window, cx: &mut Context, @@ -39,7 +40,13 @@ impl Vim { s.move_with(|map, selection| { let anchor = map.display_point_to_anchor(selection.head(), Bias::Left); selection_starts.insert(selection.id, anchor); - motion.expand_selection(map, selection, times, &text_layout_details); + motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); }); }); match mode { @@ -185,6 +192,7 @@ impl Vim { self.record_current_action(cx); self.store_visual_marks(window, cx); let count = Vim::take_count(cx).unwrap_or(1) as u32; + Vim::take_forced_motion(cx); self.update_editor(window, cx, |vim, editor, window, cx| { let mut ranges = Vec::new(); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index afd6bc402c..583e775fc6 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -18,6 +18,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -33,9 +34,13 @@ impl Vim { s.move_with(|map, selection| { let original_head = selection.head(); original_columns.insert(selection.id, original_head.column()); - let kind = - motion.expand_selection(map, selection, times, &text_layout_details); - + let kind = motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); ranges_to_copy .push(selection.start.to_point(map)..selection.end.to_point(map)); diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index 194a5c8803..e092249e32 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -29,12 +29,14 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, action: &Increment, window, cx| { vim.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); let step = if action.step { count as i32 } else { 0 }; vim.increment(count as i64, step, window, cx) }); Vim::action(editor, cx, |vim, action: &Decrement, window, cx| { vim.record_current_action(cx); let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); let step = if action.step { -1 * (count as i32) } else { 0 }; vim.increment(-(count as i64), step, window, cx) }); diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 2aaa2a4b7c..3d0a3e44c8 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -28,6 +28,7 @@ impl Vim { self.record_current_action(cx); self.store_visual_marks(window, cx); let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); self.update_editor(window, cx, |vim, editor, window, cx| { let text_layout_details = editor.text_layout_details(window); @@ -247,6 +248,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -258,7 +260,13 @@ impl Vim { editor.set_clip_at_line_ends(false, cx); editor.change_selections(None, window, cx, |s| { s.move_with(|map, selection| { - motion.expand_selection(map, selection, times, &text_layout_details); + motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); }); }); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index d396d0ae4d..49f07954ff 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -170,6 +170,7 @@ impl Vim { cx: &mut Context, ) { let mut count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); self.clear_operator(window, cx); let globals = Vim::globals(cx); @@ -201,6 +202,7 @@ impl Vim { cx: &mut Context, ) { let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| { let actions = globals.recorded_actions.clone(); diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index 0b87a3b345..dfca3aa280 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -55,6 +55,7 @@ impl Vim { by: fn(c: Option) -> ScrollAmount, ) { let amount = by(Vim::take_count(cx).map(|c| c as f32)); + Vim::take_forced_motion(cx); self.update_editor(window, cx, |_, editor, window, cx| { scroll_editor(editor, move_cursor, &amount, window, cx) }); diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 98972097ae..da8f65c1cf 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -138,6 +138,7 @@ impl Vim { Direction::Next }; let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); let prior_selections = self.editor_selections(window, cx); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { @@ -261,6 +262,7 @@ impl Vim { return; }; let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); let prior_selections = self.editor_selections(window, cx); let success = pane.update(cx, |pane, cx| { @@ -303,6 +305,7 @@ impl Vim { return; }; let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); let prior_selections = self.editor_selections(window, cx); let cursor_word = self.editor_cursor_word(window, cx); let vim = cx.entity().clone(); diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 78c9ec5b3f..1199356995 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -13,6 +13,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Substitute, window, cx| { vim.start_recording(cx); let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); vim.substitute(count, vim.mode == Mode::VisualLine, window, cx); }); @@ -22,6 +23,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { vim.switch_mode(Mode::VisualLine, false, window, cx) } let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); vim.substitute(count, true, window, cx) }); } @@ -47,6 +49,7 @@ impl Vim { selection, count, &text_layout_details, + false, ); } if line_mode { @@ -60,6 +63,7 @@ impl Vim { selection, None, &text_layout_details, + false, ); if let Some((point, _)) = (Motion::FirstNonWhitespace { display_lines: false, diff --git a/crates/vim/src/normal/toggle_comments.rs b/crates/vim/src/normal/toggle_comments.rs index 363215ffe2..1df381acbe 100644 --- a/crates/vim/src/normal/toggle_comments.rs +++ b/crates/vim/src/normal/toggle_comments.rs @@ -9,6 +9,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -21,7 +22,13 @@ impl Vim { s.move_with(|map, selection| { let anchor = map.display_point_to_anchor(selection.head(), Bias::Right); selection_starts.insert(selection.id, anchor); - motion.expand_selection(map, selection, times, &text_layout_details); + motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); }); }); editor.toggle_comments(&Default::default(), window, cx); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 0ec19f654b..6f83b954b2 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -21,6 +21,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -33,8 +34,19 @@ impl Vim { editor.change_selections(None, window, cx, |s| { s.move_with(|map, selection| { let original_position = (selection.head(), selection.goal); - original_positions.insert(selection.id, original_position); - kind = motion.expand_selection(map, selection, times, &text_layout_details); + kind = motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); + if kind == Some(MotionKind::Exclusive) { + original_positions + .insert(selection.id, (selection.start, selection.goal)); + } else { + original_positions.insert(selection.id, original_position); + } }) }); let Some(kind) = kind else { return }; diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 26437550a1..f975aefa33 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -27,6 +27,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { return; } let count = Vim::take_count(cx); + Vim::take_forced_motion(cx); vim.undo_replace(count, window, cx) }); } @@ -179,6 +180,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -188,7 +190,13 @@ impl Vim { let text_layout_details = editor.text_layout_details(window); let mut selection = editor.selections.newest_display(cx); let snapshot = editor.snapshot(window, cx); - motion.expand_selection(&snapshot, &mut selection, times, &text_layout_details); + motion.expand_selection( + &snapshot, + &mut selection, + times, + &text_layout_details, + forced_motion, + ); let start = snapshot .buffer_snapshot .anchor_before(selection.start.to_point(&snapshot)); diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index f7f234c742..b5d69ef0ae 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -10,6 +10,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &Rewrap, window, cx| { vim.record_current_action(cx); Vim::take_count(cx); + Vim::take_forced_motion(cx); vim.store_visual_marks(window, cx); vim.update_editor(window, cx, |vim, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -43,6 +44,7 @@ impl Vim { &mut self, motion: Motion, times: Option, + forced_motion: bool, window: &mut Window, cx: &mut Context, ) { @@ -55,7 +57,13 @@ impl Vim { s.move_with(|map, selection| { let anchor = map.display_point_to_anchor(selection.head(), Bias::Right); selection_starts.insert(selection.id, anchor); - motion.expand_selection(map, selection, times, &text_layout_details); + motion.expand_selection( + map, + selection, + times, + &text_layout_details, + forced_motion, + ); }); }); editor.rewrap_impl( diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 6e7f753def..6b1a87aec7 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -202,7 +202,7 @@ pub struct VimGlobals { pub pre_count: Option, /// post_count is the number after an operator is specified (2 in 3d2d) pub post_count: Option, - + pub forced_motion: bool, pub stop_recording_after_next_action: bool, pub ignore_current_insertion: bool, pub recorded_count: Option, diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 3c450292e1..6697742e4d 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -27,6 +27,7 @@ impl Vim { ) { self.stop_recording(cx); let count = Vim::take_count(cx); + let forced_motion = Vim::take_forced_motion(cx); let mode = self.mode; self.update_editor(window, cx, |_, editor, window, cx| { let text_layout_details = editor.text_layout_details(window); @@ -55,7 +56,13 @@ impl Vim { } SurroundsType::Motion(motion) => { motion - .range(&display_map, selection.clone(), count, &text_layout_details) + .range( + &display_map, + selection.clone(), + count, + &text_layout_details, + forced_motion, + ) .map(|(mut range, _)| { // The Motion::CurrentLine operation will contain the newline of the current line and leading/trailing whitespace if let Motion::CurrentLine = motion { diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index e2189da86b..053e1e587e 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -13,7 +13,7 @@ use super::{VimTestContext, neovim_connection::NeovimConnection}; use crate::state::{Mode, VimGlobals}; pub struct NeovimBackedTestContext { - cx: VimTestContext, + pub(crate) cx: VimTestContext, pub(crate) neovim: NeovimConnection, last_set_state: Option, diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 32e8de3af5..188ae1c248 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -142,6 +142,10 @@ impl VimTestContext { self.update_editor(|editor, _, cx| editor.addon::().unwrap().entity.read(cx).mode) } + pub fn forced_motion(&mut self) -> bool { + self.update_editor(|_, _, cx| cx.global::().forced_motion) + } + pub fn active_operator(&mut self) -> Option { self.update_editor(|editor, _, cx| { editor diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c4efb2b513..a1ecab13c3 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -145,6 +145,7 @@ actions!( PushDeleteSurrounds, PushMark, ToggleMarksView, + PushForcedMotion, PushIndent, PushOutdent, PushAutoIndent, @@ -233,6 +234,7 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &ResizePaneRight, window, cx| { let count = Vim::take_count(cx).unwrap_or(1) as f32; + Vim::take_forced_motion(cx); let theme = ThemeSettings::get_global(cx); let Ok(font_id) = window.text_system().font_id(&theme.buffer_font) else { return; @@ -248,6 +250,7 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &ResizePaneLeft, window, cx| { let count = Vim::take_count(cx).unwrap_or(1) as f32; + Vim::take_forced_motion(cx); let theme = ThemeSettings::get_global(cx); let Ok(font_id) = window.text_system().font_id(&theme.buffer_font) else { return; @@ -263,6 +266,7 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &ResizePaneUp, window, cx| { let count = Vim::take_count(cx).unwrap_or(1) as f32; + Vim::take_forced_motion(cx); let theme = ThemeSettings::get_global(cx); let height = theme.buffer_font_size(cx) * theme.buffer_line_height.value(); workspace.resize_pane(Axis::Vertical, height * count, window, cx); @@ -270,6 +274,7 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &ResizePaneDown, window, cx| { let count = Vim::take_count(cx).unwrap_or(1) as f32; + Vim::take_forced_motion(cx); let theme = ThemeSettings::get_global(cx); let height = theme.buffer_font_size(cx) * theme.buffer_line_height.value(); workspace.resize_pane(Axis::Vertical, -height * count, window, cx); @@ -472,7 +477,9 @@ impl Vim { vim.switch_mode(Mode::HelixNormal, false, window, cx) }, ); - + Vim::action(editor, cx, |_, _: &PushForcedMotion, _, cx| { + Vim::globals(cx).forced_motion = true; + }); Vim::action(editor, cx, |vim, action: &PushObject, window, cx| { vim.push_operator( Operator::Object { @@ -907,6 +914,7 @@ impl Vim { self.current_tx.take(); self.current_anchor.take(); } + Vim::take_forced_motion(cx); if mode != Mode::Insert && mode != Mode::Replace { Vim::take_count(cx); } @@ -1011,6 +1019,13 @@ impl Vim { count } + pub fn take_forced_motion(cx: &mut App) -> bool { + let global_state = cx.global_mut::(); + let forced_motion = global_state.forced_motion; + global_state.forced_motion = false; + forced_motion + } + pub fn cursor_shape(&self, cx: &mut App) -> CursorShape { match self.mode { Mode::Normal => { @@ -1372,6 +1387,7 @@ impl Vim { fn clear_operator(&mut self, window: &mut Window, cx: &mut Context) { Vim::take_count(cx); + Vim::take_forced_motion(cx); self.selected_register.take(); self.operator_stack.clear(); self.sync_vim_settings(window, cx); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index a96d49a43c..6827c2c055 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -85,6 +85,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &SelectLargerSyntaxNode, window, cx| { let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); for _ in 0..count { vim.update_editor(window, cx, |_, editor, window, cx| { editor.select_larger_syntax_node(&Default::default(), window, cx); @@ -97,6 +98,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { cx, |vim, _: &SelectSmallerSyntaxNode, window, cx| { let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); for _ in 0..count { vim.update_editor(window, cx, |_, editor, window, cx| { editor.select_smaller_syntax_node(&Default::default(), window, cx); @@ -682,6 +684,7 @@ impl Vim { } pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(window, cx, |_, editor, window, cx| { @@ -704,6 +707,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { + Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(window, cx, |_, editor, window, cx| { @@ -725,6 +729,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { + Vim::take_forced_motion(cx); let count = Vim::take_count(cx).unwrap_or(1); let Some(pane) = self.pane(window, cx) else { return; diff --git a/crates/vim/test_data/test_forced_motion_delete_to_end_of_line.json b/crates/vim/test_data/test_forced_motion_delete_to_end_of_line.json new file mode 100644 index 0000000000..4df916befb --- /dev/null +++ b/crates/vim/test_data/test_forced_motion_delete_to_end_of_line.json @@ -0,0 +1,10 @@ +{"Put":{"state":"the quick brown foˇx\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"$"} +{"Get":{"state":"the quick brown foˇx\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"$"} +{"Get":{"state":"ˇx\njumped over the lazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_forced_motion_delete_to_start_of_line.json b/crates/vim/test_data/test_forced_motion_delete_to_start_of_line.json new file mode 100644 index 0000000000..8aae77c8de --- /dev/null +++ b/crates/vim/test_data/test_forced_motion_delete_to_start_of_line.json @@ -0,0 +1,15 @@ +{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"0"} +{"Get":{"state":"ˇhe quick brown fox\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick bˇrown fox\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"0"} +{"Get":{"state":"ˇown fox\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick brown foˇx\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"0"} +{"Get":{"state":"ˇ\njumped over the lazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_forced_motion_yank.json b/crates/vim/test_data/test_forced_motion_yank.json new file mode 100644 index 0000000000..208c22d689 --- /dev/null +++ b/crates/vim/test_data/test_forced_motion_yank.json @@ -0,0 +1,24 @@ +{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}} +{"Key":"y"} +{"Key":"v"} +{"Key":"j"} +{"Key":"p"} +{"Get":{"state":"the quick brown fox\nˇthe quick brown fox\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick bˇrown fox\njumped over the lazy dog"}} +{"Key":"y"} +{"Key":"v"} +{"Key":"j"} +{"Key":"p"} +{"Get":{"state":"the quick brˇrown fox\njumped overown fox\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick brown foˇx\njumped over the lazy dog"}} +{"Key":"y"} +{"Key":"v"} +{"Key":"j"} +{"Key":"p"} +{"Get":{"state":"the quick brown foxˇx\njumped over the la\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick brown fox\njˇumped over the lazy dog"}} +{"Key":"y"} +{"Key":"v"} +{"Key":"k"} +{"Key":"p"} +{"Get":{"state":"thˇhe quick brown fox\nje quick brown fox\njumped over the lazy dog","mode":"Normal"}} diff --git a/crates/vim/test_data/test_inclusive_to_exclusive_delete.json b/crates/vim/test_data/test_inclusive_to_exclusive_delete.json new file mode 100644 index 0000000000..3d25b9fc67 --- /dev/null +++ b/crates/vim/test_data/test_inclusive_to_exclusive_delete.json @@ -0,0 +1,15 @@ +{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"e"} +{"Get":{"state":"ˇe quick brown fox\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick bˇrown fox\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"e"} +{"Get":{"state":"the quick bˇn fox\njumped over the lazy dog","mode":"Normal"}} +{"Put":{"state":"the quick brown foˇx\njumped over the lazy dog"}} +{"Key":"d"} +{"Key":"v"} +{"Key":"e"} +{"Get":{"state":"the quick brown foˇd over the lazy dog","mode":"Normal"}} From 7caa2c2ea07144bab8d45f3243b5a593e47c1711 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 11 Apr 2025 19:16:26 +0200 Subject: [PATCH 05/75] debugger: Prompt user when they try to close a running debug session (#28584) Closes #ISSUE Release Notes: - N/A --- crates/debugger_ui/src/debugger_panel.rs | 61 +++++++++++++++++------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index d9327711f9..378709450e 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -415,32 +415,58 @@ impl DebugPanel { }) } - fn close_session(&mut self, entity_id: EntityId, cx: &mut Context) { + fn close_session(&mut self, entity_id: EntityId, window: &mut Window, cx: &mut Context) { let Some(session) = self .sessions .iter() .find(|other| entity_id == other.entity_id()) + .cloned() else { return; }; - session.update(cx, |session, cx| session.shutdown(cx)); + let session_id = session.update(cx, |this, cx| this.session_id(cx)); + let should_prompt = self + .project + .update(cx, |this, cx| { + let session = this.dap_store().read(cx).session_by_id(session_id); + session.map(|session| !session.read(cx).is_terminated()) + }) + .ok() + .flatten() + .unwrap_or_default(); - self.sessions.retain(|other| entity_id != other.entity_id()); - - if let Some(active_session_id) = self - .active_session - .as_ref() - .map(|session| session.entity_id()) - { - if active_session_id == entity_id { - self.active_session = self.sessions.first().cloned(); + cx.spawn_in(window, async move |this, cx| { + if should_prompt { + let response = cx.prompt( + gpui::PromptLevel::Warning, + "This Debug Session is still running. Are you sure you want to terminate it?", + None, + &["Yes", "No"], + ); + if response.await == Ok(1) { + return; + } } - } + session.update(cx, |session, cx| session.shutdown(cx)).ok(); + this.update(cx, |this, cx| { + this.sessions.retain(|other| entity_id != other.entity_id()); - cx.notify(); + if let Some(active_session_id) = this + .active_session + .as_ref() + .map(|session| session.entity_id()) + { + if active_session_id == entity_id { + this.active_session = this.sessions.first().cloned(); + } + } + cx.notify() + }) + .ok(); + }) + .detach(); } - fn sessions_drop_down_menu( &self, active_session: &Entity, @@ -487,8 +513,11 @@ impl DebugPanel { let weak = weak.clone(); move |_, window, cx| { weak.update(cx, |panel, cx| { - panel - .close_session(weak_session_id, cx); + panel.close_session( + weak_session_id, + window, + cx, + ); }) .ok(); context_menu From 66b3e03baaa660d4ace61e76f65967a4f1ad3fad Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 11 Apr 2025 13:26:39 -0400 Subject: [PATCH 06/75] Fix a bug causing stale optimistic state in the git panel (#28588) Release Notes: - Fixed a bug that caused the staged status of files in the git panel to be out of date in some cases. --- Cargo.lock | 2 -- crates/project/src/git_store.rs | 6 ------ crates/project_panel/src/project_panel_tests.rs | 14 ++++++++++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ce3131d66..3e55bb6f3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3180,7 +3180,6 @@ dependencies = [ "collections", "component", "db", - "futures 0.3.31", "gpui", "languages", "notifications", @@ -3188,7 +3187,6 @@ dependencies = [ "serde", "ui", "ui_input", - "util", "workspace", "workspace-hack", ] diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index a2386ad34c..9a1683b379 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -3796,12 +3796,6 @@ impl Repository { updates_tx: Option>, cx: &mut Context, ) { - self.paths_changed( - vec![git::repository::WORK_DIRECTORY_REPO_PATH.clone()], - updates_tx.clone(), - cx, - ); - let this = cx.weak_entity(); let _ = self.send_keyed_job( Some(GitJobKey::ReloadGitState), diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index e35e5d25c5..990b446dcb 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -2070,6 +2070,20 @@ async fn test_select_git_entry(cx: &mut gpui::TestAppContext) { cx, ) .await; + + let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx); + let worktree1 = worktrees.next().unwrap(); + let worktree2 = worktrees.next().unwrap(); + ( + worktree1.read(cx).as_local().unwrap().scan_complete(), + worktree2.read(cx).as_local().unwrap().scan_complete(), + ) + }); + scan1_complete.await; + scan2_complete.await; + cx.run_until_parked(); + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*workspace, cx); let panel = workspace.update(cx, ProjectPanel::new).unwrap(); From c2e3134963c92f26cff94c7cc29a08ea7e57b180 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 11 Apr 2025 11:38:14 -0600 Subject: [PATCH 07/75] Try to weak-link ScreenCaptureKit always (#28585) Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/cli/build.rs | 2 -- crates/gpui/build.rs | 4 ++-- crates/gpui/src/platform/mac/platform.rs | 9 +++++---- crates/gpui/src/platform/mac/screen_capture.rs | 3 --- crates/gpui/src/platform/mac/window.rs | 2 +- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/cli/build.rs b/crates/cli/build.rs index f07d12546a..d41647c696 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -7,8 +7,6 @@ fn main() { if cfg!(target_os = "macos") { println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.15.7"); - // Weakly link ScreenCaptureKit to ensure can be used on macOS 10.15+. - println!("cargo:rustc-link-arg=-Wl,-weak_framework,ScreenCaptureKit"); } // Populate git sha environment variable if git is available diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 9c2b0bafa9..e30a7648a8 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -77,8 +77,8 @@ mod macos { fn generate_dispatch_bindings() { println!("cargo:rustc-link-lib=framework=System"); - println!("cargo:rustc-link-lib=framework=ScreenCaptureKit"); - println!("cargo:rerun-if-changed=src/platform/mac/dispatch.h"); + // weak link to support Catalina + println!("cargo:rustc-link-arg=-Wl,-weak_framework,ScreenCaptureKit"); let bindings = bindgen::Builder::default() .header("src/platform/mac/dispatch.h") diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 0bda71369e..759e5462d0 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -2,7 +2,7 @@ use super::{ BoolExt, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, - renderer, screen_capture, + is_macos_version_at_least, renderer, screen_capture, }; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, @@ -22,8 +22,8 @@ use cocoa::{ }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ - NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString, - NSUInteger, NSURL, + NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSOperatingSystemVersion, + NSProcessInfo, NSRange, NSString, NSUInteger, NSURL, }, }; use core_foundation::{ @@ -553,7 +553,8 @@ impl Platform for MacPlatform { } fn is_screen_capture_supported(&self) -> bool { - true + let min_version = NSOperatingSystemVersion::new(12, 3, 0); + is_macos_version_at_least(min_version) } fn screen_capture_sources( diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 8e9fc3d3f9..ac2503bb20 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -37,9 +37,6 @@ pub struct MacScreenCaptureStream { sc_stream_output: id, } -#[link(name = "ScreenCaptureKit", kind = "framework")] -unsafe extern "C" {} - static mut DELEGATE_CLASS: *const Class = ptr::null(); static mut OUTPUT_CLASS: *const Class = ptr::null(); const FRAME_CALLBACK_IVAR: &str = "frame_callback"; diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 532856d890..26a62aeadf 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1568,7 +1568,7 @@ extern "C" fn window_will_exit_fullscreen(this: &Object, _: Sel, _: id) { } } -fn is_macos_version_at_least(version: NSOperatingSystemVersion) -> bool { +pub(crate) fn is_macos_version_at_least(version: NSOperatingSystemVersion) -> bool { unsafe { NSProcessInfo::processInfo(nil).isOperatingSystemAtLeastVersion(version) } } From 78662f8fea0a910a4ec3ca25b1b78363a1cdba75 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 11 Apr 2025 19:40:25 +0200 Subject: [PATCH 08/75] debugger: UI refinements (#28589) - Name of source is only used as a fallback if there's no path - Make the frames a bit more compact. ![image](https://github.com/user-attachments/assets/74772455-c16e-477f-a962-dffd4575e557) Release Notes: - N/A --- crates/debugger_ui/src/session/running.rs | 2 + .../src/session/running/stack_frame_list.rs | 58 +++++++------------ 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index b836a05db9..25386ae005 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -275,6 +275,8 @@ fn new_debugger_pane( let active_pane_item = pane.active_item(); h_flex() .w_full() + .px_2() + .gap_1() .h(Tab::container_height(cx)) .drag_over::(|bar, _, _, cx| { bar.bg(cx.theme().colors().drop_target_background) diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 12dbef1722..bbb6e1f684 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -321,11 +321,15 @@ impl StackFrameList { let source = stack_frame.source.clone(); let is_selected_frame = Some(stack_frame.id) == self.selected_stack_frame_id; - let formatted_path = format!( - "{}:{}", - source.clone().and_then(|s| s.name).unwrap_or_default(), - stack_frame.line, - ); + let path = source.clone().and_then(|s| s.path.or(s.name)); + let formatted_path = path.map(|path| format!("{}:{}", path, stack_frame.line,)); + let formatted_path = formatted_path.map(|path| { + Label::new(path) + .size(LabelSize::XSmall) + .line_height_style(LineHeightStyle::UiLabel) + .truncate() + .color(Color::Muted) + }); let supports_frame_restart = self .session @@ -334,32 +338,19 @@ impl StackFrameList { .supports_restart_frame .unwrap_or_default(); - let origin = stack_frame - .source - .to_owned() - .and_then(|source| source.origin); - + let should_deemphasize = matches!( + stack_frame.presentation_hint, + Some( + dap::StackFramePresentationHint::Subtle + | dap::StackFramePresentationHint::Deemphasize + ) + ); h_flex() .rounded_md() .justify_between() .w_full() .group("") .id(("stack-frame", stack_frame.id)) - .tooltip({ - let formatted_path = formatted_path.clone(); - move |_window, app| { - app.new(|_| { - let mut tooltip = Tooltip::new(formatted_path.clone()); - - if let Some(origin) = &origin { - tooltip = tooltip.meta(origin); - } - - tooltip - }) - .into() - } - }) .p_1() .when(is_selected_frame, |this| { this.bg(cx.theme().colors().element_hover) @@ -374,21 +365,14 @@ impl StackFrameList { .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) .child( v_flex() + .gap_0p5() .child( - h_flex() - .gap_0p5() - .text_ui_sm(cx) + Label::new(stack_frame.name.clone()) + .size(LabelSize::Small) .truncate() - .child(stack_frame.name.clone()) - .child(formatted_path), + .when(should_deemphasize, |this| this.color(Color::Muted)), ) - .child( - h_flex() - .text_ui_xs(cx) - .truncate() - .text_color(cx.theme().colors().text_muted) - .when_some(source.and_then(|s| s.path), |this, path| this.child(path)), - ), + .children(formatted_path), ) .when( supports_frame_restart && stack_frame.can_restart.unwrap_or(true), From 5909d1258b9e14394873cc27d0452c0621e3e589 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 11 Apr 2025 14:05:51 -0400 Subject: [PATCH 09/75] Fix a panic in the git store (#28590) Closes #ISSUE Release Notes: - Fixed a panic that could occur when git statuses were updated. --- crates/project/src/git_store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 9a1683b379..b0b35c52bc 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -4048,7 +4048,7 @@ impl Repository { for (repo_path, status) in &*statuses.entries { changed_paths.remove(repo_path); if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left, &()) { - if &cursor.item().unwrap().status == status { + if cursor.item().is_some_and(|entry| entry.status == *status) { continue; } } From 6a60bb189b65023ed214f1648021740a8b9d31da Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 11 Apr 2025 19:15:22 +0000 Subject: [PATCH 10/75] ci: No draft releases when using 'run-bundling' (#28596) Improve the logic in around release artifact bundling. - Suppress a harmless "error: no such command: `about`" from script/generate-licenes output - Remove checks for main branch (which will never be true) - Only run `Upload Artifacts to release` when not using `run-bundling`. Prevents the creation of draft releases with just linux remote server binaries) Release Notes: - N/A --- .github/workflows/ci.yml | 34 ++++++++++++++-------------------- script/generate-licenses | 2 +- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6375d74f15..4ddb5173e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -594,7 +594,7 @@ jobs: timeout-minutes: 60 name: Linux x86_x64 release bundle runs-on: - - buildjet-16vcpu-ubuntu-2004 + - buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') @@ -622,26 +622,23 @@ jobs: - name: Create Linux .tar.gz bundle run: script/bundle-linux - - name: Upload Linux bundle to workflow run if main branch or specific label + - name: Upload Artifact to Workflow - zed (run-bundling) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: | - github.ref == 'refs/heads/main' - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + if: contains(github.event.pull_request.labels.*.name, 'run-bundling') with: name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz path: target/release/zed-*.tar.gz - - name: Upload Linux remote server to workflow run if main branch or specific label + - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: | - github.ref == 'refs/heads/main' - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + if: contains(github.event.pull_request.labels.*.name, 'run-bundling') with: name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.gz path: target/zed-remote-server-linux-x86_64.gz - - name: Upload app bundle to release + - name: Upload Artifacts to release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} @@ -680,29 +677,26 @@ jobs: # This exports RELEASE_CHANNEL into env (GITHUB_ENV) script/determine-release-channel - - name: Create and upload Linux .tar.gz bundle + - name: Create and upload Linux .tar.gz bundles run: script/bundle-linux - - name: Upload Linux bundle to workflow run if main branch or specific label + - name: Upload Artifact to Workflow - zed (run-bundling) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: | - github.ref == 'refs/heads/main' - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + if: contains(github.event.pull_request.labels.*.name, 'run-bundling') with: name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz path: target/release/zed-*.tar.gz - - name: Upload Linux remote server to workflow run if main branch or specific label + - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: | - github.ref == 'refs/heads/main' - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + if: contains(github.event.pull_request.labels.*.name, 'run-bundling') with: name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.gz path: target/zed-remote-server-linux-aarch64.gz - - name: Upload app bundle to release + - name: Upload Artifacts to release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} diff --git a/script/generate-licenses b/script/generate-licenses index 901e1040a2..9fcb2bd513 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -18,7 +18,7 @@ echo -n "" >"$OUTPUT_FILE" echo -e "\n# ###### CODE LICENSES ######\n" } >>"$OUTPUT_FILE" -if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" 2>&1 >/dev/null; then +if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" &>/dev/null; then echo "Installing cargo-about@^$CARGO_ABOUT_VERSION..." cargo install "cargo-about@^$CARGO_ABOUT_VERSION" else From 932a7c6440a25506692ff84a06edba21794a4d19 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 11 Apr 2025 20:22:36 +0000 Subject: [PATCH 11/75] keymap: Document editor::Select* actions (cmd-d, etc) (#28362) This is a no-op change which just adds comments. Release Notes: - N/A --- assets/keymaps/default-linux.json | 10 +++++----- assets/keymaps/default-macos.json | 11 +++++++---- assets/keymaps/linux/sublime_text.json | 2 ++ assets/keymaps/macos/sublime_text.json | 2 ++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2fd742c5ae..5b6e410ae9 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -352,11 +352,11 @@ "alt-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word - "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], - "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // Add selection to Next Find Match - "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], - "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], - "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], + "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand + "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch + "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "ctrl-k ctrl-i": "editor::Hover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-u": "editor::UndoSelection", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 034ac3b8a2..4b59db20a5 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -489,12 +489,15 @@ "alt-shift-down": "editor::DuplicateLineDown", "ctrl-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection "ctrl-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection - "cmd-d": ["editor::SelectNext", { "replace_newest": false }], // Add selection to Next Find Match + "cmd-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "cmd-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "cmd-f2": "editor::SelectAllMatches", // Select all occurrences of current word - "ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], - "cmd-k cmd-d": ["editor::SelectNext", { "replace_newest": true }], - "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], + "cmd-k cmd-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + // macOS binds `ctrl-cmd-d` to Show Dictionary which breaks these two binds + // To use `ctrl-cmd-d` or `ctrl-k ctrl-cmd-d` in Zed you must execute this command and then restart: + // defaults write com.apple.symbolichotkeys AppleSymbolicHotKeys -dict-add 70 'enabled' + "ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "cmd-k cmd-i": "editor::Hover", "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], "cmd-u": "editor::UndoSelection", diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index 258b8a5629..c3f56350b9 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -37,6 +37,8 @@ "ctrl-shift-a": "editor::SelectLargerSyntaxNode", "ctrl-shift-d": "editor::DuplicateSelection", "alt-f3": "editor::SelectAllMatches", // find_all_under + // "ctrl-f3": "", // find_under (cancels any selections) + // "cmd-alt-shift-g": "" // find_under_prev (cancels any selections) "f9": "editor::SortLinesCaseSensitive", "ctrl-f9": "editor::SortLinesCaseInsensitive", "f12": "editor::GoToDefinition", diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index d3929af9e9..6251ae0ccd 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -38,6 +38,8 @@ "cmd-shift-a": "editor::SelectLargerSyntaxNode", "cmd-shift-d": "editor::DuplicateSelection", "ctrl-cmd-g": "editor::SelectAllMatches", // find_all_under + // "cmd-alt-g": "", // find_under (cancels any selections) + // "cmd-alt-shift-g": "" // find_under_prev (cancels any selections) "f5": "editor::SortLinesCaseSensitive", "ctrl-f5": "editor::SortLinesCaseInsensitive", "shift-f12": "editor::FindAllReferences", From 5734ffbb1889dec918aff3193261fdd5a035b664 Mon Sep 17 00:00:00 2001 From: Sam Tay Date: Fri, 11 Apr 2025 16:37:58 -0400 Subject: [PATCH 12/75] Update haskell extension docs (#28603) In https://github.com/zed-extensions/haskell/pull/2 the HLS settings were updated to respect binary path/argument overrides. This PR just updates the docs to demonstrate this. Release Notes: - N/A --------- Co-authored-by: Peter Tripp --- docs/src/languages/haskell.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/src/languages/haskell.md b/docs/src/languages/haskell.md index 8075ec2904..fec9142a5f 100644 --- a/docs/src/languages/haskell.md +++ b/docs/src/languages/haskell.md @@ -33,4 +33,19 @@ If you need to configure haskell-language-server (hls) you can add configuration } ``` -See: official [configuring haskell-language-server](https://haskell-language-server.readthedocs.io/en/latest/configuration.html) docs for more. +See the official [configuring haskell-language-server](https://haskell-language-server.readthedocs.io/en/latest/configuration.html) docs for more options. + +If you would like to use a specific hls binary, or perhaps use [static-ls](https://github.com/josephsumabat/static-ls) as a drop-in replacement instead, you can specify the binary path and arguments: + +```json +{ + "lsp": { + "hls": { + "binary": { + "path": "static-ls", + "arguments": ["--experimentalFeatures"] + } + } + } +} +``` From a5fe6d1e61df476186d778acdb6623da4502d5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Sat, 12 Apr 2025 05:34:51 +0800 Subject: [PATCH 13/75] History manager (#26369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While working on implementing `add_recent_documents` for Windows, I found that the process is significantly more complex compared to macOS. On macOS, simply registering the `add_recent_documents` function is enough, as the system handles everything automatically. On Windows, however, there are two cases to consider: - **Files opened by the app**: These appear in the "Recent" section (as shown in the screenshot, "test.txt") and are managed automatically by Windows (by setting windows registry), similar to macOS. ![屏幕截图 2025-03-10 230738](https://github.com/user-attachments/assets/8fc8063b-4369-43cc-aaaf-7370a7d27060) - **Folders opened by the app**: This is more complicated because Windows does not handle it automatically, requiring the application to track opened folders manually. To address this, this PR introduces a `History Manager` along with `HistoryManagerEvent::Update` and `HistoryManagerEvent::Delete` events to simplify the process of managing recently opened folders. https://github.com/user-attachments/assets/a2581c15-7653-4faf-96b0-7c48ab1dcc8d Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- Cargo.lock | 1 + Cargo.toml | 12 +- crates/gpui/src/app.rs | 13 +- crates/gpui/src/platform.rs | 7 + crates/gpui/src/platform/linux/platform.rs | 4 +- crates/gpui/src/platform/windows.rs | 2 + .../src/platform/windows/destination_list.rs | 211 ++++++++++++++++++ crates/gpui/src/platform/windows/platform.rs | 80 ++++--- crates/recent_projects/src/recent_projects.rs | 12 +- crates/workspace/Cargo.toml | 5 +- crates/workspace/src/history_manager.rs | 129 +++++++++++ crates/workspace/src/persistence.rs | 2 +- crates/workspace/src/workspace.rs | 57 +++-- crates/zed/src/zed.rs | 13 +- 14 files changed, 482 insertions(+), 66 deletions(-) create mode 100644 crates/gpui/src/platform/windows/destination_list.rs create mode 100644 crates/workspace/src/history_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 3e55bb6f3e..671e33453c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17602,6 +17602,7 @@ dependencies = [ "ui", "util", "uuid", + "windows 0.61.1", "workspace-hack", "zed_actions", ] diff --git a/Cargo.toml b/Cargo.toml index e1299b7451..6802dff21c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -399,8 +399,12 @@ async-tungstenite = "0.29.1" async-watch = "0.3.1" async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] } aws-config = { version = "1.6.1", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1.2.2", features = ["hardcoded-credentials"] } -aws-sdk-bedrockruntime = { version = "1.80.0", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1.2.2", features = [ + "hardcoded-credentials", +] } +aws-sdk-bedrockruntime = { version = "1.80.0", features = [ + "behavior-version-latest", +] } aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] } base64 = "0.22" @@ -615,12 +619,10 @@ features = [ [workspace.dependencies.windows] version = "0.61" features = [ - "Foundation_Collections", "Foundation_Numerics", "Storage_Search", "Storage_Streams", "System_Threading", - "UI_StartScreen", "UI_ViewManagement", "Wdk_System_SystemServices", "Win32_Globalization", @@ -647,6 +649,7 @@ features = [ "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", + "Win32_System_Variant", "Win32_System_WinRT", "Win32_UI_Controls", "Win32_UI_HiDpi", @@ -654,6 +657,7 @@ features = [ "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_Shell_Common", + "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", ] diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index c7c2818b7e..525f9d6ac0 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -25,6 +25,7 @@ use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use context::*; pub use entity_map::*; use http_client::HttpClient; +use smallvec::SmallVec; #[cfg(any(test, feature = "test-support"))] pub use test_context::*; use util::ResultExt; @@ -1430,7 +1431,7 @@ impl App { /// Sets the right click menu for the app icon in the dock pub fn set_dock_menu(&self, menus: Vec) { - self.platform.set_dock_menu(menus, &self.keymap.borrow()); + self.platform.set_dock_menu(menus, &self.keymap.borrow()) } /// Performs the action associated with the given dock menu item, only used on Windows for now. @@ -1446,6 +1447,16 @@ impl App { self.platform.add_recent_document(path); } + /// Updates the jump list with the updated list of recent paths for the application, only used on Windows for now. + /// Note that this also sets the dock menu on Windows. + pub fn update_jump_list( + &self, + menus: Vec, + entries: Vec>, + ) -> Vec> { + self.platform.update_jump_list(menus, entries) + } + /// Dispatch an action to the currently active window or global action handler /// See [`crate::Action`] for more information on how actions work pub fn dispatch_action(&mut self, action: &dyn Action) { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 4a6ebc92f8..526d992f26 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -203,6 +203,13 @@ pub(crate) trait Platform: 'static { fn set_dock_menu(&self, menu: Vec, keymap: &Keymap); fn perform_dock_menu_action(&self, _action: usize) {} fn add_recent_document(&self, _path: &Path) {} + fn update_jump_list( + &self, + _menus: Vec, + _entries: Vec>, + ) -> Vec> { + Vec::new() + } fn on_app_menu_action(&self, callback: Box); fn on_will_open_app_menu(&self, callback: Box); fn on_validate_app_menu_command(&self, callback: Box bool>); diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index d02eea6dac..445192f07a 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -440,7 +440,9 @@ impl Platform for P { self.with_common(|common| Some(common.menus.clone())) } - fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) { + // todo(linux) + } fn path_for_auxiliary_executable(&self, _name: &str) -> Result { Err(anyhow::Error::msg( diff --git a/crates/gpui/src/platform/windows.rs b/crates/gpui/src/platform/windows.rs index 51d09f0013..b3a89e9635 100644 --- a/crates/gpui/src/platform/windows.rs +++ b/crates/gpui/src/platform/windows.rs @@ -1,4 +1,5 @@ mod clipboard; +mod destination_list; mod direct_write; mod dispatcher; mod display; @@ -10,6 +11,7 @@ mod window; mod wrapper; pub(crate) use clipboard::*; +pub(crate) use destination_list::*; pub(crate) use direct_write::*; pub(crate) use dispatcher::*; pub(crate) use display::*; diff --git a/crates/gpui/src/platform/windows/destination_list.rs b/crates/gpui/src/platform/windows/destination_list.rs new file mode 100644 index 0000000000..09b47a3ea4 --- /dev/null +++ b/crates/gpui/src/platform/windows/destination_list.rs @@ -0,0 +1,211 @@ +use std::path::PathBuf; + +use itertools::Itertools; +use smallvec::SmallVec; +use windows::{ + Win32::{ + Foundation::PROPERTYKEY, + Globalization::u_strlen, + System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, StructuredStorage::PROPVARIANT}, + UI::{ + Controls::INFOTIPSIZE, + Shell::{ + Common::{IObjectArray, IObjectCollection}, + DestinationList, EnumerableObjectCollection, ICustomDestinationList, IShellLinkW, + PropertiesSystem::IPropertyStore, + ShellLink, + }, + }, + }, + core::{GUID, HSTRING, Interface}, +}; + +use crate::{Action, MenuItem}; + +pub(crate) struct JumpList { + pub(crate) dock_menus: Vec, + pub(crate) recent_workspaces: Vec>, +} + +impl JumpList { + pub(crate) fn new() -> Self { + Self { + dock_menus: Vec::new(), + recent_workspaces: Vec::new(), + } + } +} + +pub(crate) struct DockMenuItem { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) action: Box, +} + +impl DockMenuItem { + pub(crate) fn new(item: MenuItem) -> anyhow::Result { + match item { + MenuItem::Action { name, action, .. } => Ok(Self { + name: name.clone().into(), + description: if name == "New Window" { + "Opens a new window".to_string() + } else { + name.into() + }, + action, + }), + _ => Err(anyhow::anyhow!( + "Only `MenuItem::Action` is supported for dock menu on Windows." + )), + } + } +} + +// This code is based on the example from Microsoft: +// https://github.com/microsoft/Windows-classic-samples/blob/main/Samples/Win7Samples/winui/shell/appshellintegration/RecipePropertyHandler/RecipePropertyHandler.cpp +pub(crate) fn update_jump_list( + jump_list: &JumpList, +) -> anyhow::Result>> { + let (list, removed) = create_destination_list()?; + add_recent_folders(&list, &jump_list.recent_workspaces, removed.as_ref())?; + add_dock_menu(&list, &jump_list.dock_menus)?; + unsafe { list.CommitList() }?; + Ok(removed) +} + +// Copied from: +// https://github.com/microsoft/windows-rs/blob/0fc3c2e5a13d4316d242bdeb0a52af611eba8bd4/crates/libs/windows/src/Windows/Win32/Storage/EnhancedStorage/mod.rs#L1881 +const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0xf29f85e0_4ff9_1068_ab91_08002b27b3d9), + pid: 2, +}; + +fn create_destination_list() -> anyhow::Result<(ICustomDestinationList, Vec>)> +{ + let list: ICustomDestinationList = + unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER) }?; + + let mut slots = 0; + let user_removed: IObjectArray = unsafe { list.BeginList(&mut slots) }?; + + let count = unsafe { user_removed.GetCount() }?; + if count == 0 { + return Ok((list, Vec::new())); + } + + let mut removed = Vec::with_capacity(count as usize); + for i in 0..count { + let shell_link: IShellLinkW = unsafe { user_removed.GetAt(i)? }; + let description = { + // INFOTIPSIZE is the maximum size of the buffer + // see https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ishelllinkw-getdescription + let mut buffer = [0u16; INFOTIPSIZE as usize]; + unsafe { shell_link.GetDescription(&mut buffer)? }; + let len = unsafe { u_strlen(buffer.as_ptr()) }; + String::from_utf16_lossy(&buffer[..len as usize]) + }; + let args = description.split('\n').map(PathBuf::from).collect(); + + removed.push(args); + } + + Ok((list, removed)) +} + +fn add_dock_menu(list: &ICustomDestinationList, dock_menus: &[DockMenuItem]) -> anyhow::Result<()> { + unsafe { + let tasks: IObjectCollection = + CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?; + for (idx, dock_menu) in dock_menus.iter().enumerate() { + let argument = HSTRING::from(format!("--dock-action {}", idx)); + let description = HSTRING::from(dock_menu.description.as_str()); + let display = dock_menu.name.as_str(); + let task = create_shell_link(argument, description, None, display)?; + tasks.AddObject(&task)?; + } + list.AddUserTasks(&tasks)?; + Ok(()) + } +} + +fn add_recent_folders( + list: &ICustomDestinationList, + entries: &[SmallVec<[PathBuf; 2]>], + removed: &Vec>, +) -> anyhow::Result<()> { + unsafe { + let tasks: IObjectCollection = + CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?; + + for folder_path in entries + .iter() + .filter(|path| !is_item_in_array(path, removed)) + { + let argument = HSTRING::from( + folder_path + .iter() + .map(|path| format!("\"{}\"", path.display())) + .join(" "), + ); + + let description = HSTRING::from( + folder_path + .iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join("\n"), + ); + // simulate folder icon + // https://github.com/microsoft/vscode/blob/7a5dc239516a8953105da34f84bae152421a8886/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts#L380 + let icon = HSTRING::from("explorer.exe"); + + let display = folder_path + .iter() + .map(|p| { + p.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| p.to_string_lossy().to_string()) + }) + .join(", "); + + tasks.AddObject(&create_shell_link( + argument, + description, + Some(icon), + &display, + )?)?; + } + + list.AppendCategory(&HSTRING::from("Recent Folders"), &tasks)?; + Ok(()) + } +} + +#[inline] +fn is_item_in_array(item: &SmallVec<[PathBuf; 2]>, removed: &Vec>) -> bool { + removed.iter().any(|removed_item| removed_item == item) +} + +fn create_shell_link( + argument: HSTRING, + description: HSTRING, + icon: Option, + display: &str, +) -> anyhow::Result { + unsafe { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + let exe_path = HSTRING::from(std::env::current_exe()?.as_os_str()); + link.SetPath(&exe_path)?; + link.SetArguments(&argument)?; + link.SetDescription(&description)?; + if let Some(icon) = icon { + link.SetIconLocation(&icon, 0)?; + } + let store: IPropertyStore = link.cast()?; + let title = PROPVARIANT::from(display); + store.SetValue(&PKEY_TITLE, &title)?; + store.Commit()?; + + Ok(link) + } +} diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 116b2253d1..7889c89a9e 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -14,10 +14,7 @@ use itertools::Itertools; use parking_lot::RwLock; use smallvec::SmallVec; use windows::{ - UI::{ - StartScreen::{JumpList, JumpListItem}, - ViewManagement::UISettings, - }, + UI::ViewManagement::UISettings, Win32::{ Foundation::*, Graphics::{ @@ -52,7 +49,7 @@ pub(crate) struct WindowsPlatform { pub(crate) struct WindowsPlatformState { callbacks: PlatformCallbacks, menus: Vec, - dock_menu_actions: Vec>, + jump_list: JumpList, // NOTE: standard cursor handles don't need to close. pub(crate) current_cursor: Option, } @@ -70,12 +67,12 @@ struct PlatformCallbacks { impl WindowsPlatformState { fn new() -> Self { let callbacks = PlatformCallbacks::default(); - let dock_menu_actions = Vec::new(); + let jump_list = JumpList::new(); let current_cursor = load_cursor(CursorStyle::Arrow); Self { callbacks, - dock_menu_actions, + jump_list, current_cursor, menus: Vec::new(), } @@ -189,9 +186,10 @@ impl WindowsPlatform { let mut lock = self.state.borrow_mut(); if let Some(mut callback) = lock.callbacks.app_menu_action.take() { let Some(action) = lock - .dock_menu_actions + .jump_list + .dock_menus .get(action_idx) - .map(|action| action.boxed_clone()) + .map(|dock_menu| dock_menu.action.boxed_clone()) else { lock.callbacks.app_menu_action = Some(callback); log::error!("Dock menu for index {action_idx} not found"); @@ -254,33 +252,35 @@ impl WindowsPlatform { false } - fn configure_jump_list(&self, menus: Vec) -> Result<()> { - let jump_list = JumpList::LoadCurrentAsync()?.get()?; - let items = jump_list.Items()?; - items.Clear()?; + fn set_dock_menus(&self, menus: Vec) { let mut actions = Vec::new(); - for item in menus.into_iter() { - let item = match item { - MenuItem::Separator => JumpListItem::CreateSeparator()?, - MenuItem::Submenu(_) => { - log::error!("Set `MenuItemSubmenu` for dock menu on Windows is not supported."); - continue; - } - MenuItem::Action { name, action, .. } => { - let idx = actions.len(); - actions.push(action.boxed_clone()); - let item_args = format!("--dock-action {}", idx); - JumpListItem::CreateWithArguments( - &HSTRING::from(item_args), - &HSTRING::from(name.as_ref()), - )? - } - }; - items.Append(&item)?; - } - jump_list.SaveAsync()?.get()?; - self.state.borrow_mut().dock_menu_actions = actions; - Ok(()) + menus.into_iter().for_each(|menu| { + if let Some(dock_menu) = DockMenuItem::new(menu).log_err() { + actions.push(dock_menu); + } + }); + let mut lock = self.state.borrow_mut(); + lock.jump_list.dock_menus = actions; + update_jump_list(&lock.jump_list).log_err(); + } + + fn update_jump_list( + &self, + menus: Vec, + entries: Vec>, + ) -> Vec> { + let mut actions = Vec::new(); + menus.into_iter().for_each(|menu| { + if let Some(dock_menu) = DockMenuItem::new(menu).log_err() { + actions.push(dock_menu); + } + }); + let mut lock = self.state.borrow_mut(); + lock.jump_list.dock_menus = actions; + lock.jump_list.recent_workspaces = entries; + update_jump_list(&lock.jump_list) + .log_err() + .unwrap_or_default() } } @@ -535,7 +535,7 @@ impl Platform for WindowsPlatform { } fn set_dock_menu(&self, menus: Vec, _keymap: &Keymap) { - self.configure_jump_list(menus).log_err(); + self.set_dock_menus(menus); } fn on_app_menu_action(&self, callback: Box) { @@ -673,6 +673,14 @@ impl Platform for WindowsPlatform { .log_err(); } } + + fn update_jump_list( + &self, + menus: Vec, + entries: Vec>, + ) -> Vec> { + self.update_jump_list(menus, entries) + } } impl Drop for WindowsPlatform { diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 77feba9f2c..3d65bcac02 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -24,8 +24,8 @@ use std::{ use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container}; use util::{ResultExt, paths::PathExt}; use workspace::{ - CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, - WorkspaceId, + CloseIntent, HistoryManager, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB, + Workspace, WorkspaceId, }; use zed_actions::{OpenRecent, OpenRemote}; @@ -553,7 +553,13 @@ impl RecentProjectsDelegate { .delegate .set_selected_index(ix.saturating_sub(1), window, cx); picker.delegate.reset_selected_match_index = false; - picker.update_matches(picker.query(cx), window, cx) + picker.update_matches(picker.query(cx), window, cx); + // After deleting a project, we want to update the history manager to reflect the change. + // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`. + if let Some(history_manager) = HistoryManager::global(cx) { + history_manager + .update(cx, |this, cx| this.delete_history(workspace_id, cx)); + } }) }) .detach(); diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index aa257a5fc9..63a57fe14a 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -67,6 +67,9 @@ uuid.workspace = true zed_actions.workspace = true workspace-hack.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + [dev-dependencies] call = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } @@ -78,5 +81,5 @@ gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } tempfile.workspace = true diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs new file mode 100644 index 0000000000..e63b1823ea --- /dev/null +++ b/crates/workspace/src/history_manager.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +use gpui::{AppContext, Entity, Global, MenuItem}; +use smallvec::SmallVec; +use ui::App; +use util::{ResultExt, paths::PathExt}; + +use crate::{NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId}; + +pub fn init(cx: &mut App) { + let manager = cx.new(|_| HistoryManager::new()); + HistoryManager::set_global(manager.clone(), cx); + HistoryManager::init(manager, cx); +} + +pub struct HistoryManager { + /// The history of workspaces that have been opened in the past, in reverse order. + /// The most recent workspace is at the end of the vector. + history: Vec, +} + +#[derive(Debug)] +pub struct HistoryManagerEntry { + pub id: WorkspaceId, + pub path: SmallVec<[PathBuf; 2]>, +} + +struct GlobalHistoryManager(Entity); + +impl Global for GlobalHistoryManager {} + +impl HistoryManager { + fn new() -> Self { + Self { + history: Vec::new(), + } + } + + fn init(this: Entity, cx: &App) { + cx.spawn(async move |cx| { + let recent_folders = WORKSPACE_DB + .recent_workspaces_on_disk() + .await + .unwrap_or_default() + .into_iter() + .rev() + .map(|(id, location)| HistoryManagerEntry::new(id, &location)) + .collect::>(); + this.update(cx, |this, cx| { + this.history = recent_folders; + this.update_jump_list(cx); + }) + }) + .detach(); + } + + pub fn global(cx: &App) -> Option> { + cx.try_global::() + .map(|model| model.0.clone()) + } + + fn set_global(history_manager: Entity, cx: &mut App) { + cx.set_global(GlobalHistoryManager(history_manager)); + } + + pub fn update_history(&mut self, id: WorkspaceId, entry: HistoryManagerEntry, cx: &App) { + if let Some(pos) = self.history.iter().position(|e| e.id == id) { + self.history.remove(pos); + } + self.history.push(entry); + self.update_jump_list(cx); + } + + pub fn delete_history(&mut self, id: WorkspaceId, cx: &App) { + let Some(pos) = self.history.iter().position(|e| e.id == id) else { + return; + }; + self.history.remove(pos); + self.update_jump_list(cx); + } + + fn update_jump_list(&mut self, cx: &App) { + let menus = vec![MenuItem::action("New Window", NewWindow)]; + let entries = self + .history + .iter() + .rev() + .map(|entry| entry.path.clone()) + .collect::>(); + let user_removed = cx.update_jump_list(menus, entries); + self.remove_user_removed_workspaces(user_removed, cx); + } + + pub fn remove_user_removed_workspaces( + &mut self, + user_removed: Vec>, + cx: &App, + ) { + if user_removed.is_empty() { + return; + } + let mut deleted_ids = Vec::new(); + for idx in (0..self.history.len()).rev() { + if let Some(entry) = self.history.get(idx) { + if user_removed.contains(&entry.path) { + deleted_ids.push(entry.id); + self.history.remove(idx); + } + } + } + cx.spawn(async move |_| { + for id in deleted_ids.iter() { + WORKSPACE_DB.delete_workspace_by_id(*id).await.log_err(); + } + }) + .detach(); + } +} + +impl HistoryManagerEntry { + pub fn new(id: WorkspaceId, location: &SerializedWorkspaceLocation) -> Self { + let path = location + .sorted_paths() + .iter() + .map(|path| path.compact()) + .collect::>(); + Self { id, path } + } +} diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 286a744569..06a84773ce 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -745,7 +745,7 @@ impl WorkspaceDb { conn.exec_bound(sql!( DELETE FROM pane_groups WHERE workspace_id = ?1; DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id) - .context("Clearing old panes")?; + .context("Clearing old panes")?; conn.exec_bound(sql!(DELETE FROM breakpoints WHERE workspace_id = ?1))?(workspace.id).context("Clearing old breakpoints")?; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 01d836f48d..6c06489c44 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,4 +1,5 @@ pub mod dock; +pub mod history_manager; pub mod item; mod modal_layer; pub mod notifications; @@ -43,6 +44,7 @@ use gpui::{ WindowHandle, WindowId, WindowOptions, action_as, actions, canvas, impl_action_as, impl_actions, point, relative, size, transparent_black, }; +pub use history_manager::*; pub use item::{ FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings, ProjectItem, SerializableItem, SerializableItemHandle, WeakItemHandle, @@ -387,6 +389,7 @@ pub fn init(app_state: Arc, cx: &mut App) { component::init(); theme_preview::init(cx); toast_layer::init(cx); + history_manager::init(cx); cx.on_action(Workspace::close_global); cx.on_action(reload); @@ -902,6 +905,9 @@ impl Workspace { project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => { this.update_window_title(window, cx); this.serialize_workspace(window, cx); + // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`. + // So we need to update the history. + this.update_history(cx); } project::Event::DisconnectedFromHost => { @@ -1334,7 +1340,10 @@ impl Workspace { .unwrap_or_default(); window - .update(cx, |_, window, _| window.activate_window()) + .update(cx, |workspace, window, cx| { + window.activate_window(); + workspace.update_history(cx); + }) .log_err(); Ok((window, opened_items)) }) @@ -4707,19 +4716,7 @@ impl Workspace { } } - let location = if let Some(ssh_project) = &self.serialized_ssh_project { - Some(SerializedWorkspaceLocation::Ssh(ssh_project.clone())) - } else if let Some(local_paths) = self.local_paths(cx) { - if !local_paths.is_empty() { - Some(SerializedWorkspaceLocation::from_local_paths(local_paths)) - } else { - None - } - } else { - None - }; - - if let Some(location) = location { + if let Some(location) = self.serialize_workspace_location(cx) { let breakpoints = self.project.update(cx, |project, cx| { project.breakpoint_store().read(cx).all_breakpoints(cx) }); @@ -4739,13 +4736,42 @@ impl Workspace { breakpoints, window_id: Some(window.window_handle().window_id().as_u64()), }; + return window.spawn(cx, async move |_| { - persistence::DB.save_workspace(serialized_workspace).await + persistence::DB.save_workspace(serialized_workspace).await; }); } Task::ready(()) } + fn serialize_workspace_location(&self, cx: &App) -> Option { + if let Some(ssh_project) = &self.serialized_ssh_project { + Some(SerializedWorkspaceLocation::Ssh(ssh_project.clone())) + } else if let Some(local_paths) = self.local_paths(cx) { + if !local_paths.is_empty() { + Some(SerializedWorkspaceLocation::from_local_paths(local_paths)) + } else { + None + } + } else { + None + } + } + + fn update_history(&self, cx: &mut App) { + let Some(id) = self.database_id() else { + return; + }; + let Some(location) = self.serialize_workspace_location(cx) else { + return; + }; + if let Some(manager) = HistoryManager::global(cx) { + manager.update(cx, |this, cx| { + this.update_history(id, HistoryManagerEntry::new(id, &location), cx); + }); + } + } + async fn serialize_items( this: &WeakEntity, items_rx: UnboundedReceiver>, @@ -6614,6 +6640,7 @@ async fn open_ssh_project_inner( let mut workspace = Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx); workspace.set_serialized_ssh_project(serialized_ssh_project); + workspace.update_history(cx); workspace }); })?; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index fdf7a88f7b..03a7ad149e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -26,9 +26,9 @@ use git_ui::git_panel::GitPanel; use git_ui::project_diff::ProjectDiffToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, Context, DismissEvent, Element, Entity, - Focusable, KeyBinding, MenuItem, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, - SharedString, Styled, Task, TitlebarOptions, UpdateGlobal, Window, WindowKind, WindowOptions, - actions, point, px, + Focusable, KeyBinding, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString, + Styled, Task, TitlebarOptions, UpdateGlobal, Window, WindowKind, WindowOptions, actions, point, + px, }; use image_viewer::ImageInfo; use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType}; @@ -1386,7 +1386,12 @@ fn reload_keymaps(cx: &mut App, user_key_bindings: Vec) { load_default_keymap(cx); cx.bind_keys(user_key_bindings); cx.set_menus(app_menus()); - cx.set_dock_menu(vec![MenuItem::action("New Window", workspace::NewWindow)]); + // On Windows, this is set in the `update_jump_list` method of the `HistoryManager`. + #[cfg(not(target_os = "windows"))] + cx.set_dock_menu(vec![gpui::MenuItem::action( + "New Window", + workspace::NewWindow, + )]); } pub fn load_default_keymap(cx: &mut App) { From 141ad72d9794a78c6b107cb1c3e920e77aa6c039 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 11 Apr 2025 16:01:25 -0600 Subject: [PATCH 14/75] extension: Use `heck` instead of `convert_case` for snake_case check (#28608) This PR updates the snake_case check for grammar names to use `heck` instead of `convert_case`. `heck` correctly handles values like `d2`. Fixes https://github.com/zed-industries/zed/issues/28583. Release Notes: - Updated snake_case check for grammar names in extensions. --- Cargo.lock | 2 +- Cargo.toml | 1 + crates/extension/Cargo.toml | 2 +- crates/extension/src/extension_builder.rs | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 671e33453c..fc906cf259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4979,10 +4979,10 @@ dependencies = [ "async-tar", "async-trait", "collections", - "convert_case 0.8.0", "fs", "futures 0.3.31", "gpui", + "heck 0.5.0", "http_client", "language", "log", diff --git a/Cargo.toml b/Cargo.toml index 6802dff21c..70634e87bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -443,6 +443,7 @@ futures-lite = "1.13" git2 = { version = "0.20.1", default-features = false } globset = "0.4" handlebars = "4.3" +heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" html5ever = "0.27.0" diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 5031e1cb85..cf89f41dda 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -17,10 +17,10 @@ async-compression.workspace = true async-tar.workspace = true async-trait.workspace = true collections.workspace = true -convert_case.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true +heck.workspace = true http_client.workspace = true language.workspace = true log.workspace = true diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 162f926dda..c6636f03d2 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -4,9 +4,9 @@ use crate::{ use anyhow::{Context as _, Result, anyhow, bail}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; -use convert_case::{Case, Casing as _}; use futures::AsyncReadExt; use futures::io::BufReader; +use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; use serde::Deserialize; use std::{ @@ -106,7 +106,7 @@ impl ExtensionBuilder { } for (grammar_name, grammar_metadata) in &extension_manifest.grammars { - let snake_cased_grammar_name = grammar_name.to_case(Case::Snake); + let snake_cased_grammar_name = grammar_name.to_snake_case(); if grammar_name.as_ref() != snake_cased_grammar_name.as_str() { bail!( "grammar name '{grammar_name}' must be written in snake_case: {snake_cased_grammar_name}" From 730f2e70835eca30215770fa5204f56c3bc8aa7c Mon Sep 17 00:00:00 2001 From: 5brian Date: Fri, 11 Apr 2025 18:03:05 -0400 Subject: [PATCH 15/75] vim: Add highlighting to set commands (#28600) |Before|After| |--|--| |![image](https://github.com/user-attachments/assets/fb965e1f-658c-4ecd-a51f-821881b8001a)|![image](https://github.com/user-attachments/assets/f05f73bf-6661-406a-a5d6-e121e5b6fd1a)| Release Notes: - N/A --- crates/vim/src/command.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 15f008f91d..264edca0b1 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -963,7 +963,15 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Vec Date: Fri, 11 Apr 2025 18:20:43 -0400 Subject: [PATCH 16/75] snippets: Fix snippets for PHP and ERB languages (#27718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21541 Closes #22726 This should fix snippets in languages, like PHP, that are based on the HTML syntax layer. To be honest, I don't totally get where HTML comes into it, but the issues outlined in #21541 and #22726 both boil down to "Zed only shows me HTML snippets in PHP/ERB files; I expected to see PHP/ERB snippets". This solution is based on the comments between @mrnugget and @osiewicz in #22726: resolve/combine snippets for all language layers at the given position, whereas current behavior is to resolve snippets only for the `.last()` language layer at the given position. - add `Buffer:languages_at()` (note the plural) - update `snippet_completions()` in `editor.rs` to loop over each language, gathering snippets as it goes - the primary logic for resolving snippets within a single language has not changed ### Verifying this change I couldn't find tests related to snippet and currently active languages (CI may show them to me 😆 ) but I can add some if desired and w/ perhaps a little coaching or prompting about another test to look to for inspiration. I have confirmed that this works for PHP, but I have not checked ERB because I'm not familiar with it or set up for it. To check this manually: 1. install the PHP extension 2. install at least 1 snippet for each of html, php and phpdoc. If you don't have any, these should work: ```sh # BEWARE these will clobber existing snippets! echo '{"dddd":{"body":"hello from phpdoc"}}' > ~/.config/zed/snippets/phpdoc.json echo '{"pppp":{"body":"hello from PHP"}}' > ~/.config/zed/snippets/php.json echo '{"hhhh":{"body":"hello from HTML"}}' > ~/.config/zed/snippets/html.json ``` 3. open any PHP file. If you don't have one, here's one that should work: ```php Task>> { - let language = buffer.read(cx).language_at(buffer_position); - let language_name = language.as_ref().map(|language| language.lsp_id()); + let languages = buffer.read(cx).languages_at(buffer_position); let snippet_store = project.snippets().read(cx); - let snippets = snippet_store.snippets_for(language_name, cx); - if snippets.is_empty() { + let scopes: Vec<_> = languages + .iter() + .filter_map(|language| { + let language_name = language.lsp_id(); + let snippets = snippet_store.snippets_for(Some(language_name), cx); + + if snippets.is_empty() { + None + } else { + Some((language.default_scope(), snippets)) + } + }) + .collect(); + + if scopes.is_empty() { return Task::ready(Ok(vec![])); } + let snapshot = buffer.read(cx).text_snapshot(); let chars: String = snapshot .reversed_chars_for_range(text::Anchor::MIN..buffer_position) .collect(); - - let scope = language.map(|language| language.default_scope()); let executor = cx.background_executor().clone(); cx.background_spawn(async move { - let classifier = CharClassifier::new(scope).for_completion(true); - let mut last_word = chars - .chars() - .take_while(|c| classifier.is_word(*c)) - .collect::(); - last_word = last_word.chars().rev().collect(); + let mut all_results: Vec = Vec::new(); + for (scope, snippets) in scopes.into_iter() { + let classifier = CharClassifier::new(Some(scope)).for_completion(true); + let mut last_word = chars + .chars() + .take_while(|c| classifier.is_word(*c)) + .collect::(); + last_word = last_word.chars().rev().collect(); - if last_word.is_empty() { - return Ok(vec![]); - } + if last_word.is_empty() { + return Ok(vec![]); + } - let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_position); + let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_position); - let candidates = snippets - .iter() - .enumerate() - .flat_map(|(ix, snippet)| { - snippet - .prefix - .iter() - .map(move |prefix| StringMatchCandidate::new(ix, &prefix)) - }) - .collect::>(); - - let mut matches = fuzzy::match_strings( - &candidates, - &last_word, - last_word.chars().any(|c| c.is_uppercase()), - 100, - &Default::default(), - executor, - ) - .await; - - // Remove all candidates where the query's start does not match the start of any word in the candidate - if let Some(query_start) = last_word.chars().next() { - matches.retain(|string_match| { - split_words(&string_match.string).any(|word| { - // Check that the first codepoint of the word as lowercase matches the first - // codepoint of the query as lowercase - word.chars() - .flat_map(|codepoint| codepoint.to_lowercase()) - .zip(query_start.to_lowercase()) - .all(|(word_cp, query_cp)| word_cp == query_cp) + let candidates = snippets + .iter() + .enumerate() + .flat_map(|(ix, snippet)| { + snippet + .prefix + .iter() + .map(move |prefix| StringMatchCandidate::new(ix, &prefix)) }) - }); - } + .collect::>(); - let matched_strings = matches - .into_iter() - .map(|m| m.string) - .collect::>(); + let mut matches = fuzzy::match_strings( + &candidates, + &last_word, + last_word.chars().any(|c| c.is_uppercase()), + 100, + &Default::default(), + executor.clone(), + ) + .await; - let result: Vec = snippets - .into_iter() - .filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| matched_strings.contains(*prefix))?; - let start = as_offset - last_word.len(); - let start = snapshot.anchor_before(start); - let range = start..buffer_position; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Some(Completion { - replace_range: range, - new_text: snippet.body.clone(), - source: CompletionSource::Lsp { - insert_range: None, - server_id: LanguageServerId(usize::MAX), - resolved: true, - lsp_completion: Box::new(lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } + // Remove all candidates where the query's start does not match the start of any word in the candidate + if let Some(query_start) = last_word.chars().next() { + matches.retain(|string_match| { + split_words(&string_match.string).any(|word| { + // Check that the first codepoint of the word as lowercase matches the first + // codepoint of the query as lowercase + word.chars() + .flat_map(|codepoint| codepoint.to_lowercase()) + .zip(query_start.to_lowercase()) + .all(|(word_cp, query_cp)| word_cp == query_cp) + }) + }); + } + + let matched_strings = matches + .into_iter() + .map(|m| m.string) + .collect::>(); + + let mut result: Vec = snippets + .iter() + .filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| matched_strings.contains(*prefix))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + replace_range: range, + new_text: snippet.body.clone(), + source: CompletionSource::Lsp { + insert_range: None, + server_id: LanguageServerId(usize::MAX), + resolved: true, + lsp_completion: Box::new(lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..lsp::CompletionItem::default() }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..lsp::CompletionItem::default() + lsp_defaults: None, + }, + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, + icon_path: None, + documentation: snippet.description.clone().map(|description| { + CompletionDocumentation::SingleLine(description.into()) }), - lsp_defaults: None, - }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, - icon_path: None, - documentation: snippet - .description - .clone() - .map(|description| CompletionDocumentation::SingleLine(description.into())), - insert_text_mode: None, - confirm: None, + insert_text_mode: None, + confirm: None, + }) }) - }) - .collect(); + .collect(); - Ok(result) + all_results.append(&mut result); + } + + Ok(all_results) }) } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 45535971f7..aeb870288c 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1373,6 +1373,16 @@ impl Buffer { .or_else(|| self.language.clone()) } + /// Returns each [`Language`] for the active syntax layers at the given location. + pub fn languages_at(&self, position: D) -> Vec> { + let offset = position.to_offset(self); + self.syntax_map + .lock() + .layers_for_range(offset..offset, &self.text, false) + .map(|info| info.language.clone()) + .collect() + } + /// An integer version number that accounts for all updates besides /// the buffer's text itself (which is versioned via a version vector). pub fn non_text_state_update_count(&self) -> usize { From 5994ac5cecc4bccdc7b0d5c65a0fdd30e92fb106 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 11 Apr 2025 16:26:41 -0600 Subject: [PATCH 17/75] Use NoopTextSystem during tests (#28607) This should allow tests to be more similar across platforms. Release Notes: - N/A --- crates/editor/src/display_map.rs | 5 +- crates/editor/src/display_map/block_map.rs | 3 +- crates/editor/src/editor_tests.rs | 2 - crates/editor/src/element.rs | 2 +- crates/gpui/src/platform.rs | 102 ++++++++++++++++---- crates/gpui/src/platform/test/platform.rs | 18 +--- crates/gpui/src/text_system/line_wrapper.rs | 19 ++-- 7 files changed, 97 insertions(+), 54 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 896ee0be81..d94d9cb51d 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1742,7 +1742,6 @@ pub mod tests { } } - #[cfg(target_os = "macos")] #[gpui::test(retries = 5)] async fn test_soft_wraps(cx: &mut gpui::TestAppContext) { cx.background_executor @@ -1760,7 +1759,7 @@ pub mod tests { editor.update(cx, |editor, _cx| editor.text_layout_details(window)); let font_size = px(12.0); - let wrap_width = Some(px(64.)); + let wrap_width = Some(px(96.)); let text = "one two three four five\nsix seven eight"; let buffer = MultiBuffer::build_simple(text, cx); @@ -2411,8 +2410,6 @@ pub mod tests { } } - // todo(linux) fails due to pixel differences in text rendering - #[cfg(target_os = "macos")] #[gpui::test] async fn test_chunks_with_soft_wrapping(cx: &mut gpui::TestAppContext) { cx.background_executor diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 3e8aaaaefb..f57ae8b96b 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -2277,7 +2277,6 @@ mod tests { } } - #[cfg(target_os = "macos")] #[gpui::test] fn test_blocks_on_wrapped_lines(cx: &mut gpui::TestAppContext) { cx.update(init_test); @@ -2292,7 +2291,7 @@ mod tests { let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); let (_, wraps_snapshot) = cx.update(|cx| { - WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), Some(px(60.)), cx) + WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), Some(px(90.)), cx) }); let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b7354bdb9e..78f9e67df6 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -1316,8 +1316,6 @@ fn test_move_cursor(cx: &mut TestAppContext) { }); } -// TODO: Re-enable this test -#[cfg(target_os = "macos")] #[gpui::test] fn test_move_cursor_multibyte(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index dffa4da3e1..a05e39e2f0 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -6638,7 +6638,7 @@ impl Element for EditorElement { } }; - if editor.set_wrap_width(wrap_width, cx) { + if editor.set_wrap_width(wrap_width.map(|w| w.ceil()), cx) { editor.snapshot(window, cx) } else { snapshot diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 526d992f26..969e51e44d 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -37,9 +37,10 @@ use crate::{ DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene, - SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window, point, + ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window, + point, px, size, }; -use anyhow::{Result, anyhow}; +use anyhow::Result; use async_task::Runnable; use futures::channel::oneshot; use image::codecs::gif::GifDecoder; @@ -532,40 +533,105 @@ impl PlatformTextSystem for NoopTextSystem { Vec::new() } - fn font_id(&self, descriptor: &Font) -> Result { - Err(anyhow!("No font found for {:?}", descriptor)) + fn font_id(&self, _descriptor: &Font) -> Result { + return Ok(FontId(1)); } fn font_metrics(&self, _font_id: FontId) -> FontMetrics { - unimplemented!() + FontMetrics { + units_per_em: 1000, + ascent: 1025.0, + descent: -275.0, + line_gap: 0.0, + underline_position: -95.0, + underline_thickness: 60.0, + cap_height: 698.0, + x_height: 516.0, + bounding_box: Bounds { + origin: Point { + x: -260.0, + y: -245.0, + }, + size: Size { + width: 1501.0, + height: 1364.0, + }, + }, + } } - fn typographic_bounds(&self, font_id: FontId, _glyph_id: GlyphId) -> Result> { - Err(anyhow!("No font found for {:?}", font_id)) + fn typographic_bounds(&self, _font_id: FontId, _glyph_id: GlyphId) -> Result> { + Ok(Bounds { + origin: Point { x: 54.0, y: 0.0 }, + size: size(392.0, 528.0), + }) } - fn advance(&self, font_id: FontId, _glyph_id: GlyphId) -> Result> { - Err(anyhow!("No font found for {:?}", font_id)) + fn advance(&self, _font_id: FontId, glyph_id: GlyphId) -> Result> { + Ok(size(600.0 * glyph_id.0 as f32, 0.0)) } - fn glyph_for_char(&self, _font_id: FontId, _ch: char) -> Option { - None + fn glyph_for_char(&self, _font_id: FontId, ch: char) -> Option { + Some(GlyphId(ch.len_utf16() as u32)) } - fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { - Err(anyhow!("No font found for {:?}", params)) + fn glyph_raster_bounds(&self, _params: &RenderGlyphParams) -> Result> { + Ok(Default::default()) } fn rasterize_glyph( &self, - params: &RenderGlyphParams, - _raster_bounds: Bounds, + _params: &RenderGlyphParams, + raster_bounds: Bounds, ) -> Result<(Size, Vec)> { - Err(anyhow!("No font found for {:?}", params)) + Ok((raster_bounds.size, Vec::new())) } - fn layout_line(&self, _text: &str, _font_size: Pixels, _runs: &[FontRun]) -> LineLayout { - unimplemented!() + fn layout_line(&self, text: &str, font_size: Pixels, _runs: &[FontRun]) -> LineLayout { + let mut position = px(0.); + let metrics = self.font_metrics(FontId(0)); + let em_width = font_size + * self + .advance(FontId(0), self.glyph_for_char(FontId(0), 'm').unwrap()) + .unwrap() + .width + / metrics.units_per_em as f32; + let mut glyphs = SmallVec::default(); + for (ix, c) in text.char_indices() { + if let Some(glyph) = self.glyph_for_char(FontId(0), c) { + glyphs.push(ShapedGlyph { + id: glyph, + position: point(position, px(0.)), + index: ix, + is_emoji: glyph.0 == 2, + }); + if glyph.0 == 2 { + position += em_width * 2.0; + } else { + position += em_width; + } + } else { + position += em_width + } + } + let mut runs = Vec::default(); + if glyphs.len() > 0 { + runs.push(ShapedRun { + font_id: FontId(0), + glyphs, + }); + } else { + position = px(0.); + } + + LineLayout { + font_size, + width: position, + ascent: font_size * (metrics.ascent / metrics.units_per_em as f32), + descent: font_size * (metrics.descent / metrics.units_per_em as f32), + runs, + len: text.len(), + } } } diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 90e3cf2fa6..3902a98a65 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,8 +1,8 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - ForegroundExecutor, Keymap, Platform, PlatformDisplay, PlatformTextSystem, ScreenCaptureFrame, - ScreenCaptureSource, ScreenCaptureStream, Size, Task, TestDisplay, TestWindow, - WindowAppearance, WindowParams, size, + ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformTextSystem, + ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, Size, Task, TestDisplay, + TestWindow, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -91,17 +91,7 @@ impl TestPlatform { ) }; - #[cfg(target_os = "macos")] - let text_system = Arc::new(crate::platform::mac::MacTextSystem::new()); - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - let text_system = Arc::new(crate::platform::linux::CosmicTextSystem::new()); - - #[cfg(target_os = "windows")] - let text_system = Arc::new( - crate::platform::windows::DirectWriteTextSystem::new(&bitmap_factory) - .expect("Unable to initialize direct write."), - ); + let text_system = Arc::new(NoopTextSystem); Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index f0dfc927e5..d7bc4c1f24 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -330,13 +330,6 @@ mod tests { fn build_wrapper() -> LineWrapper { let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); let cx = TestAppContext::new(dispatcher, None); - cx.text_system() - .add_fonts(vec![ - std::fs::read("../../assets/fonts/plex-mono/ZedPlexMono-Regular.ttf") - .unwrap() - .into(), - ]) - .unwrap(); let id = cx.text_system().font_id(&font("Zed Plex Mono")).unwrap(); LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone()) } @@ -734,16 +727,16 @@ mod tests { lines[0].layout.wrap_boundaries(), &[ WrapBoundary { - run_ix: 1, - glyph_ix: 3 + run_ix: 0, + glyph_ix: 7 }, WrapBoundary { - run_ix: 2, - glyph_ix: 3 + run_ix: 0, + glyph_ix: 12 }, WrapBoundary { - run_ix: 4, - glyph_ix: 2 + run_ix: 0, + glyph_ix: 18 } ], ); From dafe994eef0ef1e31e2ec44b3c34878c0ac63cf0 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 11 Apr 2025 16:27:24 -0600 Subject: [PATCH 18/75] agent: Register tracked buffers with language servers (#28610) Release Notes: - agent: Start language servers when accessing files via tools Co-authored-by: Michael --- crates/assistant_tool/src/action_log.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index 1c0911c189..fa305a512e 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -4,7 +4,7 @@ use collections::BTreeMap; use futures::{StreamExt, channel::mpsc}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity}; use language::{Anchor, Buffer, BufferEvent, DiskState, Point}; -use project::{Project, ProjectItem}; +use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; use std::{cmp, ops::Range, sync::Arc}; use text::{Edit, Patch, Rope}; use util::RangeExt; @@ -49,6 +49,10 @@ impl ActionLog { .tracked_buffers .entry(buffer.clone()) .or_insert_with(|| { + let open_lsp_handle = self.project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + let text_snapshot = buffer.read(cx).text_snapshot(); let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); @@ -76,6 +80,7 @@ impl ActionLog { version: buffer.read(cx).version(), diff, diff_update: diff_update_tx, + _open_lsp_handle: open_lsp_handle, _maintain_diff: cx.spawn({ let buffer = buffer.clone(); async move |this, cx| { @@ -615,6 +620,7 @@ struct TrackedBuffer { diff: Entity, snapshot: text::BufferSnapshot, diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>, + _open_lsp_handle: OpenLspBufferHandle, _maintain_diff: Task<()>, _subscription: Subscription, } From b22faf96e00297f43e362e5e274b4d2e817a9682 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 11 Apr 2025 17:02:50 -0600 Subject: [PATCH 19/75] agent: Refine language model selector (#28597) Release Notes: - agent: Show recommended models in the agent model selector and display the provider in the model selector's trigger. --------- Co-authored-by: Danilo Leal Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> --- Cargo.lock | 1 + assets/icons/ai_anthropic_hosted.svg | 12 - crates/agent/src/assistant_model_selector.rs | 19 +- crates/file_finder/src/file_finder_tests.rs | 14 +- crates/icons/src/icons.rs | 1 - crates/language_model/src/language_model.rs | 7 +- .../language_model/src/model/cloud_model.rs | 8 - crates/language_model_selector/Cargo.toml | 1 + .../src/language_model_selector.rs | 351 ++++++++++-------- .../language_models/src/provider/anthropic.rs | 30 +- crates/language_models/src/provider/cloud.rs | 39 +- crates/picker/src/picker.rs | 83 ++++- crates/prompt_library/src/prompt_library.rs | 2 +- 13 files changed, 350 insertions(+), 218 deletions(-) delete mode 100644 assets/icons/ai_anthropic_hosted.svg diff --git a/Cargo.lock b/Cargo.lock index fc906cf259..9af249804c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7657,6 +7657,7 @@ dependencies = [ name = "language_model_selector" version = "0.1.0" dependencies = [ + "collections", "feature_flags", "gpui", "language_model", diff --git a/assets/icons/ai_anthropic_hosted.svg b/assets/icons/ai_anthropic_hosted.svg deleted file mode 100644 index b088520490..0000000000 --- a/assets/icons/ai_anthropic_hosted.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/crates/agent/src/assistant_model_selector.rs b/crates/agent/src/assistant_model_selector.rs index 11726b2574..091071af29 100644 --- a/crates/agent/src/assistant_model_selector.rs +++ b/crates/agent/src/assistant_model_selector.rs @@ -80,17 +80,16 @@ impl AssistantModelSelector { impl Render for AssistantModelSelector { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let model_registry = LanguageModelRegistry::read_global(cx); + let focus_handle = self.focus_handle.clone(); + let model_registry = LanguageModelRegistry::read_global(cx); let model = match self.model_type { ModelType::Default => model_registry.default_model(), ModelType::InlineAssistant => model_registry.inline_assistant_model(), }; - - let focus_handle = self.focus_handle.clone(); - let model_name = match model { - Some(model) => model.model.name().0, - _ => SharedString::from("No model selected"), + let (model_name, model_icon) = match model { + Some(model) => (model.model.name().0, Some(model.provider.icon())), + _ => (SharedString::from("No model selected"), None), }; LanguageModelSelectorPopoverMenu::new( @@ -100,10 +99,16 @@ impl Render for AssistantModelSelector { .child( h_flex() .gap_0p5() + .children( + model_icon.map(|icon| { + Icon::new(icon).color(Color::Muted).size(IconSize::Small) + }), + ) .child( Label::new(model_name) .size(LabelSize::Small) - .color(Color::Muted), + .color(Color::Muted) + .ml_1(), ) .child( Icon::new(IconName::ChevronDown) diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index d5d3582858..d2a5f1402d 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -2133,18 +2133,28 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) { cx.dispatch_action(ToggleFileFinder::default()); let picker = active_file_picker(&workspace, cx); + + picker.update_in(cx, |picker, window, cx| { + picker.update_matches(".txt".to_string(), window, cx) + }); + + cx.run_until_parked(); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 6); assert_eq!(picker.delegate.selected_index, 0); - assert_eq!(picker.logical_scroll_top_index(), 0); }); // When toggling repeatedly, the picker scrolls to reveal the selected item. cx.dispatch_action(ToggleFileFinder::default()); cx.dispatch_action(ToggleFileFinder::default()); cx.dispatch_action(ToggleFileFinder::default()); + + cx.run_until_parked(); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 6); assert_eq!(picker.delegate.selected_index, 3); - assert_eq!(picker.logical_scroll_top_index(), 3); }); } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 6c448c03ed..d7f4a820da 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -10,7 +10,6 @@ use strum::{EnumIter, EnumString, IntoStaticStr}; pub enum IconName { Ai, AiAnthropic, - AiAnthropicHosted, AiBedrock, AiDeepSeek, AiEdit, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index aa060f7b30..98456e7db4 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -174,10 +174,6 @@ impl Default for LanguageModelTextStream { pub trait LanguageModel: Send + Sync { fn id(&self) -> LanguageModelId; fn name(&self) -> LanguageModelName; - /// If None, falls back to [LanguageModelProvider::icon] - fn icon(&self) -> Option { - None - } fn provider_id(&self) -> LanguageModelProviderId; fn provider_name(&self) -> LanguageModelProviderName; fn telemetry_id(&self) -> String; @@ -304,6 +300,9 @@ pub trait LanguageModelProvider: 'static { } fn default_model(&self, cx: &App) -> Option>; fn provided_models(&self, cx: &App) -> Vec>; + fn recommended_models(&self, _cx: &App) -> Vec> { + Vec::new() + } fn load_model(&self, _model: Arc, _cx: &App) {} fn is_authenticated(&self, cx: &App) -> bool; fn authenticate(&self, cx: &mut App) -> Task>; diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index e5c66670d8..cc15ce3364 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -6,7 +6,6 @@ use client::Client; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, ReadGlobal as _, }; -use icons::IconName; use proto::{Plan, TypedEnvelope}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -53,13 +52,6 @@ impl CloudModel { } } - pub fn icon(&self) -> Option { - match self { - Self::Anthropic(_) => Some(IconName::AiAnthropicHosted), - _ => None, - } - } - pub fn max_token_count(&self) -> usize { match self { Self::Anthropic(model) => model.max_token_count(), diff --git a/crates/language_model_selector/Cargo.toml b/crates/language_model_selector/Cargo.toml index 1257ae564c..39bc8a59f9 100644 --- a/crates/language_model_selector/Cargo.toml +++ b/crates/language_model_selector/Cargo.toml @@ -12,6 +12,7 @@ workspace = true path = "src/language_model_selector.rs" [dependencies] +collections.workspace = true feature_flags.workspace = true gpui.workspace = true language_model.workspace = true diff --git a/crates/language_model_selector/src/language_model_selector.rs b/crates/language_model_selector/src/language_model_selector.rs index 90747a01f3..7f18b4d9fd 100644 --- a/crates/language_model_selector/src/language_model_selector.rs +++ b/crates/language_model_selector/src/language_model_selector.rs @@ -1,12 +1,13 @@ use std::sync::Arc; +use collections::{HashSet, IndexMap}; use feature_flags::{Assistant2FeatureFlag, ZedPro}; use gpui::{ Action, AnyElement, AnyView, App, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, action_with_deprecated_aliases, }; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelAvailability, LanguageModelRegistry, + AuthenticateError, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, }; use picker::{Picker, PickerDelegate}; use proto::Plan; @@ -24,9 +25,6 @@ type OnModelChanged = Arc, &App) + 'static>; pub struct LanguageModelSelector { picker: Entity>, - /// The task used to update the picker's matches when there is a change to - /// the language model registry. - update_matches_task: Option>, _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, } @@ -40,16 +38,18 @@ impl LanguageModelSelector { let on_model_changed = Arc::new(on_model_changed); let all_models = Self::all_models(cx); + let entries = all_models.entries(); + let delegate = LanguageModelPickerDelegate { language_model_selector: cx.entity().downgrade(), on_model_changed: on_model_changed.clone(), - all_models: all_models.clone(), - filtered_models: all_models, - selected_index: Self::get_active_model_index(cx), + all_models: Arc::new(all_models), + selected_index: Self::get_active_model_index(&entries, cx), + filtered_entries: entries, }; let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) + Picker::list(delegate, window, cx) .show_scrollbar(true) .width(rems(20.)) .max_height(Some(rems(20.).into())) @@ -59,7 +59,6 @@ impl LanguageModelSelector { LanguageModelSelector { picker, - update_matches_task: None, _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![ cx.subscribe_in( @@ -83,12 +82,13 @@ impl LanguageModelSelector { language_model::Event::ProviderStateChanged | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) => { - let task = self.picker.update(cx, |this, cx| { + self.picker.update(cx, |this, cx| { let query = this.query(cx); - this.delegate.all_models = Self::all_models(cx); - this.delegate.update_matches(query, window, cx) + this.delegate.all_models = Arc::new(Self::all_models(cx)); + // Update matches will automatically drop the previous task + // if we get a provider event again + this.update_matches(query, window, cx) }); - self.update_matches_task = Some(task); } _ => {} } @@ -144,34 +144,72 @@ impl LanguageModelSelector { }) } - fn all_models(cx: &App) -> Vec { - LanguageModelRegistry::global(cx) + fn all_models(cx: &App) -> GroupedModels { + let mut recommended = Vec::new(); + let mut recommended_set = HashSet::default(); + for provider in LanguageModelRegistry::global(cx) .read(cx) .providers() .iter() - .flat_map(|provider| { - let icon = provider.icon(); - - provider.provided_models(cx).into_iter().map(move |model| { - let model = model.clone(); - let icon = model.icon().unwrap_or(icon); - - ModelInfo { + { + let models = provider.recommended_models(cx); + recommended_set.extend(models.iter().map(|model| (model.provider_id(), model.id()))); + recommended.extend( + provider + .recommended_models(cx) + .into_iter() + .map(move |model| ModelInfo { model: model.clone(), - icon, - availability: model.availability(), - } - }) + icon: provider.icon(), + }), + ); + } + + let other_models = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| { + ( + provider.id(), + provider + .provided_models(cx) + .into_iter() + .filter_map(|model| { + let not_included = + !recommended_set.contains(&(model.provider_id(), model.id())); + not_included.then(|| ModelInfo { + model: model.clone(), + icon: provider.icon(), + }) + }) + .collect::>(), + ) }) - .collect::>() + .collect::>(); + + GroupedModels { + recommended, + other: other_models, + } } - fn get_active_model_index(cx: &App) -> usize { + fn get_active_model_index(entries: &[LanguageModelPickerEntry], cx: &App) -> usize { let active_model = LanguageModelRegistry::read_global(cx).default_model(); - Self::all_models(cx) + entries .iter() - .position(|model_info| { - Some(model_info.model.id()) == active_model.as_ref().map(|model| model.model.id()) + .position(|entry| { + if let LanguageModelPickerEntry::Model(model) = entry { + active_model + .as_ref() + .map(|active_model| { + active_model.model.id() == model.model.id() + && active_model.model.provider_id() == model.model.provider_id() + }) + .unwrap_or_default() + } else { + false + } }) .unwrap_or(0) } @@ -254,22 +292,61 @@ where struct ModelInfo { model: Arc, icon: IconName, - availability: LanguageModelAvailability, } pub struct LanguageModelPickerDelegate { language_model_selector: WeakEntity, on_model_changed: OnModelChanged, - all_models: Vec, - filtered_models: Vec, + all_models: Arc, + filtered_entries: Vec, selected_index: usize, } +struct GroupedModels { + recommended: Vec, + other: IndexMap>, +} + +impl GroupedModels { + fn entries(&self) -> Vec { + let mut entries = Vec::new(); + + if !self.recommended.is_empty() { + entries.push(LanguageModelPickerEntry::Separator("Recommended".into())); + entries.extend( + self.recommended + .iter() + .map(|info| LanguageModelPickerEntry::Model(info.clone())), + ); + } + + for models in self.other.values() { + if models.is_empty() { + continue; + } + entries.push(LanguageModelPickerEntry::Separator( + models[0].model.provider_name().0, + )); + entries.extend( + models + .iter() + .map(|info| LanguageModelPickerEntry::Model(info.clone())), + ); + } + entries + } +} + +enum LanguageModelPickerEntry { + Model(ModelInfo), + Separator(SharedString), +} + impl PickerDelegate for LanguageModelPickerDelegate { - type ListItem = ListItem; + type ListItem = AnyElement; fn match_count(&self) -> usize { - self.filtered_models.len() + self.filtered_entries.len() } fn selected_index(&self) -> usize { @@ -277,12 +354,24 @@ impl PickerDelegate for LanguageModelPickerDelegate { } fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix.min(self.filtered_models.len().saturating_sub(1)); + self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); cx.notify(); } + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + match self.filtered_entries.get(ix) { + Some(LanguageModelPickerEntry::Model(_)) => true, + Some(LanguageModelPickerEntry::Separator(_)) | None => false, + } + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select a model...".into() + "Select a model…".into() } fn update_matches( @@ -307,22 +396,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { cx.spawn_in(window, async move |this, cx| { let filtered_models = cx .background_spawn(async move { - let displayed_models = if configured_providers.is_empty() { - all_models - } else { - all_models - .into_iter() - .filter(|model_info| { - configured_providers.contains(&model_info.model.provider_id()) - }) - .collect::>() - }; - - if query.is_empty() { - displayed_models - } else { - displayed_models - .into_iter() + let filter_models = |model_infos: &[ModelInfo]| { + model_infos + .iter() .filter(|model_info| { model_info .model @@ -331,20 +407,33 @@ impl PickerDelegate for LanguageModelPickerDelegate { .to_lowercase() .contains(&query.to_lowercase()) }) - .collect() + .cloned() + .collect::>() + }; + + let recommended_models = filter_models(&all_models.recommended); + let mut other_models = IndexMap::default(); + for (provider_id, models) in &all_models.other { + if configured_providers.contains(&provider_id) { + other_models.insert(provider_id.clone(), filter_models(models)); + } + } + GroupedModels { + recommended: recommended_models, + other: other_models, } }) .await; this.update_in(cx, |this, window, cx| { - this.delegate.filtered_models = filtered_models; + this.delegate.filtered_entries = filtered_models.entries(); // Preserve selection focus - let new_index = if current_index >= this.delegate.filtered_models.len() { + let new_index = if current_index >= this.delegate.filtered_entries.len() { 0 } else { current_index }; - this.delegate.set_selected_index(new_index, window, cx); + this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx); cx.notify(); }) .ok(); @@ -352,7 +441,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { } fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some(model_info) = self.filtered_models.get(self.selected_index) { + if let Some(LanguageModelPickerEntry::Model(model_info)) = + self.filtered_entries.get(self.selected_index) + { let model = model_info.model.clone(); (self.on_model_changed)(model.clone(), cx); @@ -369,29 +460,6 @@ impl PickerDelegate for LanguageModelPickerDelegate { .ok(); } - fn render_header(&self, _: &mut Window, cx: &mut Context>) -> Option { - let configured_models_count = LanguageModelRegistry::global(cx) - .read(cx) - .providers() - .iter() - .filter(|provider| provider.is_authenticated(cx)) - .count(); - - if configured_models_count > 0 { - Some( - Label::new("Configured Models") - .size(LabelSize::Small) - .color(Color::Muted) - .mt_1() - .mb_0p5() - .ml_2() - .into_any_element(), - ) - } else { - None - } - } - fn render_match( &self, ix: usize, @@ -399,77 +467,68 @@ impl PickerDelegate for LanguageModelPickerDelegate { _: &mut Window, cx: &mut Context>, ) -> Option { - use feature_flags::FeatureFlagAppExt; - let show_badges = cx.has_flag::(); + match self.filtered_entries.get(ix)? { + LanguageModelPickerEntry::Separator(title) => Some( + div() + .px_2() + .pb_1() + .when(ix > 1, |this| { + this.mt_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(title) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + ), + LanguageModelPickerEntry::Model(model_info) => { + let active_model = LanguageModelRegistry::read_global(cx).default_model(); - let model_info = self.filtered_models.get(ix)?; - let provider_name: String = model_info.model.provider_name().0.clone().into(); + let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); + let active_model_id = active_model.map(|m| m.model.id()); - let active_model = LanguageModelRegistry::read_global(cx).default_model(); + let is_selected = Some(model_info.model.provider_id()) == active_provider_id + && Some(model_info.model.id()) == active_model_id; - let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); - let active_model_id = active_model.map(|m| m.model.id()); + let model_icon_color = if is_selected { + Color::Accent + } else { + Color::Muted + }; - let is_selected = Some(model_info.model.provider_id()) == active_provider_id - && Some(model_info.model.id()) == active_model_id; - - let model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted - }; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .start_slot( - Icon::new(model_info.icon) - .color(model_icon_color) - .size(IconSize::Small), - ) - .child( - h_flex() - .w_full() - .items_center() - .gap_1p5() - .pl_0p5() - .w(px(240.)) - .child( - div() - .max_w_40() - .child(Label::new(model_info.model.name().0.clone()).truncate()), + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .start_slot( + Icon::new(model_info.icon) + .color(model_icon_color) + .size(IconSize::Small), ) .child( h_flex() - .gap_0p5() - .child( - Label::new(provider_name) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .children(match model_info.availability { - LanguageModelAvailability::Public => None, - LanguageModelAvailability::RequiresPlan(Plan::Free) => None, - LanguageModelAvailability::RequiresPlan(Plan::ZedPro) => { - show_badges.then(|| { - Label::new("Pro") - .size(LabelSize::XSmall) - .color(Color::Muted) - }) - } - }), - ), + .w_full() + .pl_0p5() + .gap_1p5() + .w(px(240.)) + .child(Label::new(model_info.model.name().0.clone()).truncate()), + ) + .end_slot(div().pr_3().when(is_selected, |this| { + this.child( + Icon::new(IconName::Check) + .color(Color::Accent) + .size(IconSize::Small), + ) + })) + .into_any_element(), ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })), - ) + } + } } fn render_footer( diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index bce985a872..4540a08268 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -192,6 +192,16 @@ impl AnthropicLanguageModelProvider { Self { http_client, state } } + + fn create_language_model(&self, model: anthropic::Model) -> Arc { + Arc::new(AnthropicModel { + id: LanguageModelId::from(model.id().to_string()), + model, + state: self.state.clone(), + http_client: self.http_client.clone(), + request_limiter: RateLimiter::new(4), + }) as Arc + } } impl LanguageModelProviderState for AnthropicLanguageModelProvider { @@ -226,6 +236,16 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { })) } + fn recommended_models(&self, _cx: &App) -> Vec> { + [ + anthropic::Model::Claude3_7Sonnet, + anthropic::Model::Claude3_7SonnetThinking, + ] + .into_iter() + .map(|model| self.create_language_model(model)) + .collect() + } + fn provided_models(&self, cx: &App) -> Vec> { let mut models = BTreeMap::default(); @@ -266,15 +286,7 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { models .into_values() - .map(|model| { - Arc::new(AnthropicModel { - id: LanguageModelId::from(model.id().to_string()), - model, - state: self.state.clone(), - http_client: self.http_client.clone(), - request_limiter: RateLimiter::new(4), - }) as Arc - }) + .map(|model| self.create_language_model(model)) .collect() } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 9377bf315f..6a08f48522 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -225,6 +225,20 @@ impl CloudLanguageModelProvider { _maintain_client_status: maintain_client_status, } } + + fn create_language_model( + &self, + model: CloudModel, + llm_api_token: LlmApiToken, + ) -> Arc { + Arc::new(CloudLanguageModel { + id: LanguageModelId::from(model.id().to_string()), + model, + llm_api_token: llm_api_token.clone(), + client: self.client.clone(), + request_limiter: RateLimiter::new(4), + }) as Arc + } } impl LanguageModelProviderState for CloudLanguageModelProvider { @@ -260,6 +274,17 @@ impl LanguageModelProvider for CloudLanguageModelProvider { })) } + fn recommended_models(&self, cx: &App) -> Vec> { + let llm_api_token = self.state.read(cx).llm_api_token.clone(); + [ + CloudModel::Anthropic(anthropic::Model::Claude3_7Sonnet), + CloudModel::Anthropic(anthropic::Model::Claude3_7SonnetThinking), + ] + .into_iter() + .map(|model| self.create_language_model(model, llm_api_token.clone())) + .collect() + } + fn provided_models(&self, cx: &App) -> Vec> { let mut models = BTreeMap::default(); @@ -345,15 +370,7 @@ impl LanguageModelProvider for CloudLanguageModelProvider { let llm_api_token = self.state.read(cx).llm_api_token.clone(); models .into_values() - .map(|model| { - Arc::new(CloudLanguageModel { - id: LanguageModelId::from(model.id().to_string()), - model, - llm_api_token: llm_api_token.clone(), - client: self.client.clone(), - request_limiter: RateLimiter::new(4), - }) as Arc - }) + .map(|model| self.create_language_model(model, llm_api_token.clone())) .collect() } @@ -575,10 +592,6 @@ impl LanguageModel for CloudLanguageModel { LanguageModelName::from(self.model.display_name().to_string()) } - fn icon(&self) -> Option { - self.model.icon() - } - fn provider_id(&self) -> LanguageModelProviderId { LanguageModelProviderId(ZED_CLOUD_PROVIDER_ID.into()) } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2caa9ff756..54b50453ce 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -3,8 +3,8 @@ use editor::{Editor, scroll::Autoscroll}; use gpui::{ AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Render, - ScrollHandle, ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, - impl_actions, list, prelude::*, uniform_list, + ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, impl_actions, + list, prelude::*, uniform_list, }; use head::Head; use schemars::JsonSchema; @@ -24,6 +24,11 @@ enum ElementContainer { UniformList(UniformListScrollHandle), } +pub enum Direction { + Up, + Down, +} + actions!(picker, [ConfirmCompletion]); /// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally, @@ -86,6 +91,15 @@ pub trait PickerDelegate: Sized + 'static { window: &mut Window, cx: &mut Context>, ); + fn can_select( + &mut self, + _ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + true + } + // Allows binding some optional effect to when the selection changes. fn selected_index_changed( &self, @@ -271,10 +285,7 @@ impl Picker { ElementContainer::UniformList(scroll_handle) => { ScrollbarState::new(scroll_handle.clone()) } - ElementContainer::List(_) => { - // todo smit: implement for list - ScrollbarState::new(ScrollHandle::new()) - } + ElementContainer::List(state) => ScrollbarState::new(state.clone()), }; let focus_handle = cx.focus_handle(); let mut this = Self { @@ -359,16 +370,58 @@ impl Picker { } /// Handles the selecting an index, and passing the change to the delegate. - /// If `scroll_to_index` is true, the new selected index will be scrolled into view. + /// If `fallback_direction` is set to `None`, the index will not be selected + /// if the element at that index cannot be selected. + /// If `fallback_direction` is set to + /// `Some(..)`, the next selectable element will be selected in the + /// specified direction (Down or Up), cycling through all elements until + /// finding one that can be selected or returning if there are no selectable elements. + /// If `scroll_to_index` is true, the new selected index will be scrolled into + /// view. /// /// If some effect is bound to `selected_index_changed`, it will be executed. pub fn set_selected_index( &mut self, - ix: usize, + mut ix: usize, + fallback_direction: Option, scroll_to_index: bool, window: &mut Window, cx: &mut Context, ) { + let match_count = self.delegate.match_count(); + if match_count == 0 { + return; + } + + if let Some(bias) = fallback_direction { + let mut curr_ix = ix; + while !self.delegate.can_select(curr_ix, window, cx) { + curr_ix = match bias { + Direction::Down => { + if curr_ix == match_count - 1 { + 0 + } else { + curr_ix + 1 + } + } + Direction::Up => { + if curr_ix == 0 { + match_count - 1 + } else { + curr_ix - 1 + } + } + }; + // There is no item that can be selected + if ix == curr_ix { + return; + } + } + ix = curr_ix; + } else if !self.delegate.can_select(ix, window, cx) { + return; + } + let previous_index = self.delegate.selected_index(); self.delegate.set_selected_index(ix, window, cx); let current_index = self.delegate.selected_index(); @@ -393,7 +446,7 @@ impl Picker { if count > 0 { let index = self.delegate.selected_index(); let ix = if index == count - 1 { 0 } else { index + 1 }; - self.set_selected_index(ix, true, window, cx); + self.set_selected_index(ix, Some(Direction::Down), true, window, cx); cx.notify(); } } @@ -408,7 +461,7 @@ impl Picker { if count > 0 { let index = self.delegate.selected_index(); let ix = if index == 0 { count - 1 } else { index - 1 }; - self.set_selected_index(ix, true, window, cx); + self.set_selected_index(ix, Some(Direction::Up), true, window, cx); cx.notify(); } } @@ -416,7 +469,7 @@ impl Picker { fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context) { let count = self.delegate.match_count(); if count > 0 { - self.set_selected_index(0, true, window, cx); + self.set_selected_index(0, Some(Direction::Down), true, window, cx); cx.notify(); } } @@ -424,7 +477,7 @@ impl Picker { fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context) { let count = self.delegate.match_count(); if count > 0 { - self.set_selected_index(count - 1, true, window, cx); + self.set_selected_index(count - 1, Some(Direction::Up), true, window, cx); cx.notify(); } } @@ -433,7 +486,7 @@ impl Picker { let count = self.delegate.match_count(); let index = self.delegate.selected_index(); let new_index = if index + 1 == count { 0 } else { index + 1 }; - self.set_selected_index(new_index, true, window, cx); + self.set_selected_index(new_index, Some(Direction::Down), true, window, cx); cx.notify(); } @@ -506,14 +559,14 @@ impl Picker { ) { cx.stop_propagation(); window.prevent_default(); - self.set_selected_index(ix, false, window, cx); + self.set_selected_index(ix, None, false, window, cx); self.do_confirm(secondary, window, cx) } fn do_confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context) { if let Some(update_query) = self.delegate.confirm_update_query(window, cx) { self.set_query(update_query, window, cx); - self.delegate.set_selected_index(0, window, cx); + self.set_selected_index(0, Some(Direction::Down), false, window, cx); } else { self.delegate.confirm(secondary, window, cx) } diff --git a/crates/prompt_library/src/prompt_library.rs b/crates/prompt_library/src/prompt_library.rs index c2c1f3da60..7fff6d1258 100644 --- a/crates/prompt_library/src/prompt_library.rs +++ b/crates/prompt_library/src/prompt_library.rs @@ -657,7 +657,7 @@ impl PromptLibrary { .iter() .position(|mat| mat.id == prompt_id) { - picker.set_selected_index(ix, true, window, cx); + picker.set_selected_index(ix, None, true, window, cx); } } } else { From 0036a332638b9103051dec8e32886c1dc5ad0e10 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 11 Apr 2025 17:45:51 -0600 Subject: [PATCH 20/75] agent: Remove unused code (#28552) This code was used before we had a proper completion menu for at-mentions Release Notes: - N/A --- crates/agent/src/context_picker.rs | 66 ++++++-------- .../context_picker/fetch_context_picker.rs | 31 ++----- .../src/context_picker/file_context_picker.rs | 29 ++----- .../context_picker/symbol_context_picker.rs | 24 ++---- .../context_picker/thread_context_picker.rs | 25 ++---- crates/agent/src/context_strip.rs | 3 +- crates/agent/src/message_editor.rs | 85 ++++--------------- 7 files changed, 65 insertions(+), 198 deletions(-) diff --git a/crates/agent/src/context_picker.rs b/crates/agent/src/context_picker.rs index bcbee38b73..9e578c4fc0 100644 --- a/crates/agent/src/context_picker.rs +++ b/crates/agent/src/context_picker.rs @@ -34,12 +34,6 @@ use crate::context_store::ContextStore; use crate::thread::ThreadId; use crate::thread_store::ThreadStore; -#[derive(Debug, Clone, Copy)] -pub enum ConfirmBehavior { - KeepOpen, - Close, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ContextPickerMode { File, @@ -105,7 +99,6 @@ pub(super) struct ContextPicker { workspace: WeakEntity, context_store: WeakEntity, thread_store: Option>, - confirm_behavior: ConfirmBehavior, _subscriptions: Vec, } @@ -114,7 +107,6 @@ impl ContextPicker { workspace: WeakEntity, thread_store: Option>, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, window: &mut Window, cx: &mut Context, ) -> Self { @@ -143,7 +135,6 @@ impl ContextPicker { workspace, context_store, thread_store, - confirm_behavior, _subscriptions: subscriptions, } } @@ -166,37 +157,32 @@ impl ContextPicker { let modes = supported_context_picker_modes(&self.thread_store); - let menu = menu - .when(has_recent, |menu| { - menu.custom_row(|_, _| { - div() - .mb_1() - .child( - Label::new("Recent") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .into_any_element() - }) + menu.when(has_recent, |menu| { + menu.custom_row(|_, _| { + div() + .mb_1() + .child( + Label::new("Recent") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() }) - .extend(recent_entries) - .when(has_recent, |menu| menu.separator()) - .extend(modes.into_iter().map(|mode| { - let context_picker = context_picker.clone(); + }) + .extend(recent_entries) + .when(has_recent, |menu| menu.separator()) + .extend(modes.into_iter().map(|mode| { + let context_picker = context_picker.clone(); - ContextMenuEntry::new(mode.label()) - .icon(mode.icon()) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .handler(move |window, cx| { - context_picker.update(cx, |this, cx| this.select_mode(mode, window, cx)) - }) - })); - - match self.confirm_behavior { - ConfirmBehavior::KeepOpen => menu.keep_open_on_confirm(), - ConfirmBehavior::Close => menu, - } + ContextMenuEntry::new(mode.label()) + .icon(mode.icon()) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .handler(move |window, cx| { + context_picker.update(cx, |this, cx| this.select_mode(mode, window, cx)) + }) + })) + .keep_open_on_confirm() }); cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| { @@ -227,7 +213,6 @@ impl ContextPicker { context_picker.clone(), self.workspace.clone(), self.context_store.clone(), - self.confirm_behavior, window, cx, ) @@ -239,7 +224,6 @@ impl ContextPicker { context_picker.clone(), self.workspace.clone(), self.context_store.clone(), - self.confirm_behavior, window, cx, ) @@ -251,7 +235,6 @@ impl ContextPicker { context_picker.clone(), self.workspace.clone(), self.context_store.clone(), - self.confirm_behavior, window, cx, ) @@ -264,7 +247,6 @@ impl ContextPicker { thread_store.clone(), context_picker.clone(), self.context_store.clone(), - self.confirm_behavior, window, cx, ) diff --git a/crates/agent/src/context_picker/fetch_context_picker.rs b/crates/agent/src/context_picker/fetch_context_picker.rs index c4a9dd1211..5c7795237b 100644 --- a/crates/agent/src/context_picker/fetch_context_picker.rs +++ b/crates/agent/src/context_picker/fetch_context_picker.rs @@ -11,7 +11,7 @@ use picker::{Picker, PickerDelegate}; use ui::{Context, ListItem, Window, prelude::*}; use workspace::Workspace; -use crate::context_picker::{ConfirmBehavior, ContextPicker}; +use crate::context_picker::ContextPicker; use crate::context_store::ContextStore; pub struct FetchContextPicker { @@ -23,16 +23,10 @@ impl FetchContextPicker { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, window: &mut Window, cx: &mut Context, ) -> Self { - let delegate = FetchContextPickerDelegate::new( - context_picker, - workspace, - context_store, - confirm_behavior, - ); + let delegate = FetchContextPickerDelegate::new(context_picker, workspace, context_store); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } @@ -62,7 +56,6 @@ pub struct FetchContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, url: String, } @@ -71,13 +64,11 @@ impl FetchContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, ) -> Self { FetchContextPickerDelegate { context_picker, workspace, context_store, - confirm_behavior, url: String::new(), } } @@ -204,25 +195,15 @@ impl PickerDelegate for FetchContextPickerDelegate { let http_client = workspace.read(cx).client().http_client().clone(); let url = self.url.clone(); - let confirm_behavior = self.confirm_behavior; cx.spawn_in(window, async move |this, cx| { let text = cx .background_spawn(fetch_url_content(http_client, url.clone())) .await?; - this.update_in(cx, |this, window, cx| { - this.delegate - .context_store - .update(cx, |context_store, cx| { - context_store.add_fetched_url(url, text, cx) - })?; - - match confirm_behavior { - ConfirmBehavior::KeepOpen => {} - ConfirmBehavior::Close => this.delegate.dismissed(window, cx), - } - - anyhow::Ok(()) + this.update(cx, |this, cx| { + this.delegate.context_store.update(cx, |context_store, cx| { + context_store.add_fetched_url(url, text, cx) + }) })??; anyhow::Ok(()) diff --git a/crates/agent/src/context_picker/file_context_picker.rs b/crates/agent/src/context_picker/file_context_picker.rs index 965f4a530e..5981b471c2 100644 --- a/crates/agent/src/context_picker/file_context_picker.rs +++ b/crates/agent/src/context_picker/file_context_picker.rs @@ -11,9 +11,9 @@ use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; use ui::{ListItem, Tooltip, prelude::*}; use util::ResultExt as _; -use workspace::{Workspace, notifications::NotifyResultExt}; +use workspace::Workspace; -use crate::context_picker::{ConfirmBehavior, ContextPicker}; +use crate::context_picker::ContextPicker; use crate::context_store::{ContextStore, FileInclusion}; pub struct FileContextPicker { @@ -25,16 +25,10 @@ impl FileContextPicker { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, window: &mut Window, cx: &mut Context, ) -> Self { - let delegate = FileContextPickerDelegate::new( - context_picker, - workspace, - context_store, - confirm_behavior, - ); + let delegate = FileContextPickerDelegate::new(context_picker, workspace, context_store); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } @@ -57,7 +51,6 @@ pub struct FileContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, matches: Vec, selected_index: usize, } @@ -67,13 +60,11 @@ impl FileContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, ) -> Self { Self { context_picker, workspace, context_store, - confirm_behavior, matches: Vec::new(), selected_index: 0, } @@ -127,7 +118,7 @@ impl PickerDelegate for FileContextPickerDelegate { }) } - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { let Some(FileMatch { mat, .. }) = self.matches.get(self.selected_index) else { return; }; @@ -153,17 +144,7 @@ impl PickerDelegate for FileContextPickerDelegate { return; }; - let confirm_behavior = self.confirm_behavior; - cx.spawn_in(window, async move |this, cx| { - match task.await.notify_async_err(cx) { - None => anyhow::Ok(()), - Some(()) => this.update_in(cx, |this, window, cx| match confirm_behavior { - ConfirmBehavior::KeepOpen => {} - ConfirmBehavior::Close => this.delegate.dismissed(window, cx), - }), - } - }) - .detach_and_log_err(cx); + task.detach_and_log_err(cx); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { diff --git a/crates/agent/src/context_picker/symbol_context_picker.rs b/crates/agent/src/context_picker/symbol_context_picker.rs index 608accc098..b76d4a8093 100644 --- a/crates/agent/src/context_picker/symbol_context_picker.rs +++ b/crates/agent/src/context_picker/symbol_context_picker.rs @@ -15,7 +15,7 @@ use ui::{ListItem, prelude::*}; use util::ResultExt as _; use workspace::Workspace; -use crate::context_picker::{ConfirmBehavior, ContextPicker}; +use crate::context_picker::ContextPicker; use crate::context_store::ContextStore; pub struct SymbolContextPicker { @@ -27,16 +27,10 @@ impl SymbolContextPicker { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, window: &mut Window, cx: &mut Context, ) -> Self { - let delegate = SymbolContextPickerDelegate::new( - context_picker, - workspace, - context_store, - confirm_behavior, - ); + let delegate = SymbolContextPickerDelegate::new(context_picker, workspace, context_store); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } @@ -59,7 +53,6 @@ pub struct SymbolContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, matches: Vec, selected_index: usize, } @@ -69,13 +62,11 @@ impl SymbolContextPickerDelegate { context_picker: WeakEntity, workspace: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, ) -> Self { Self { context_picker, workspace, context_store, - confirm_behavior, matches: Vec::new(), selected_index: 0, } @@ -135,7 +126,7 @@ impl PickerDelegate for SymbolContextPickerDelegate { }) } - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { let Some(mat) = self.matches.get(self.selected_index) else { return; }; @@ -143,7 +134,6 @@ impl PickerDelegate for SymbolContextPickerDelegate { return; }; - let confirm_behavior = self.confirm_behavior; let add_symbol_task = add_symbol( mat.symbol.clone(), true, @@ -153,16 +143,12 @@ impl PickerDelegate for SymbolContextPickerDelegate { ); let selected_index = self.selected_index; - cx.spawn_in(window, async move |this, cx| { + cx.spawn(async move |this, cx| { let included = add_symbol_task.await?; - this.update_in(cx, |this, window, cx| { + this.update(cx, |this, _| { if let Some(mat) = this.delegate.matches.get_mut(selected_index) { mat.is_included = included; } - match confirm_behavior { - ConfirmBehavior::KeepOpen => {} - ConfirmBehavior::Close => this.delegate.dismissed(window, cx), - } }) }) .detach_and_log_err(cx); diff --git a/crates/agent/src/context_picker/thread_context_picker.rs b/crates/agent/src/context_picker/thread_context_picker.rs index 98f62b3073..941926a898 100644 --- a/crates/agent/src/context_picker/thread_context_picker.rs +++ b/crates/agent/src/context_picker/thread_context_picker.rs @@ -6,7 +6,7 @@ use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; use picker::{Picker, PickerDelegate}; use ui::{ListItem, prelude::*}; -use crate::context_picker::{ConfirmBehavior, ContextPicker}; +use crate::context_picker::ContextPicker; use crate::context_store::{self, ContextStore}; use crate::thread::ThreadId; use crate::thread_store::ThreadStore; @@ -20,16 +20,11 @@ impl ThreadContextPicker { thread_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, window: &mut Window, cx: &mut Context, ) -> Self { - let delegate = ThreadContextPickerDelegate::new( - thread_store, - context_picker, - context_store, - confirm_behavior, - ); + let delegate = + ThreadContextPickerDelegate::new(thread_store, context_picker, context_store); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); ThreadContextPicker { picker } @@ -58,7 +53,6 @@ pub struct ThreadContextPickerDelegate { thread_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, matches: Vec, selected_index: usize, } @@ -68,13 +62,11 @@ impl ThreadContextPickerDelegate { thread_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, - confirm_behavior: ConfirmBehavior, ) -> Self { ThreadContextPickerDelegate { thread_store, context_picker, context_store, - confirm_behavior, matches: Vec::new(), selected_index: 0, } @@ -127,7 +119,7 @@ impl PickerDelegate for ThreadContextPickerDelegate { }) } - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { let Some(entry) = self.matches.get(self.selected_index) else { return; }; @@ -138,20 +130,15 @@ impl PickerDelegate for ThreadContextPickerDelegate { let open_thread_task = thread_store.update(cx, |this, cx| this.open_thread(&entry.id, cx)); - cx.spawn_in(window, async move |this, cx| { + cx.spawn(async move |this, cx| { let thread = open_thread_task.await?; - this.update_in(cx, |this, window, cx| { + this.update(cx, |this, cx| { this.delegate .context_store .update(cx, |context_store, cx| { context_store.add_thread(thread, true, cx) }) .ok(); - - match this.delegate.confirm_behavior { - ConfirmBehavior::KeepOpen => {} - ConfirmBehavior::Close => this.delegate.dismissed(window, cx), - } }) }) .detach_and_log_err(cx); diff --git a/crates/agent/src/context_strip.rs b/crates/agent/src/context_strip.rs index afc61f46ce..6245f88998 100644 --- a/crates/agent/src/context_strip.rs +++ b/crates/agent/src/context_strip.rs @@ -15,7 +15,7 @@ use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; use workspace::{Workspace, notifications::NotifyResultExt}; use crate::context::{ContextId, ContextKind}; -use crate::context_picker::{ConfirmBehavior, ContextPicker}; +use crate::context_picker::ContextPicker; use crate::context_store::ContextStore; use crate::thread::Thread; use crate::thread_store::ThreadStore; @@ -52,7 +52,6 @@ impl ContextStrip { workspace.clone(), thread_store.clone(), context_store.downgrade(), - ConfirmBehavior::KeepOpen, window, cx, ) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 81adea8945..95870897f1 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -10,8 +10,8 @@ use editor::{ use file_icons::FileIcons; use fs::Fs; use gpui::{ - Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle, - WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between, + Animation, AnimationExt, App, Entity, Focusable, Subscription, TextStyle, WeakEntity, + linear_color_stop, linear_gradient, point, pulsating_between, }; use language::{Buffer, Language}; use language_model::{ConfiguredModel, LanguageModelRegistry}; @@ -21,12 +21,12 @@ use project::Project; use settings::Settings; use std::time::Duration; use theme::ThemeSettings; -use ui::{Disclosure, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; use util::ResultExt as _; use workspace::Workspace; use crate::assistant_model_selector::AssistantModelSelector; -use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider}; +use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider}; use crate::context_store::{ContextStore, refresh_context_store_text}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::profile_selector::ProfileSelector; @@ -46,8 +46,6 @@ pub struct MessageEditor { context_store: Entity, context_strip: Entity, context_picker_menu_handle: PopoverMenuHandle, - inline_context_picker: Entity, - inline_context_picker_menu_handle: PopoverMenuHandle, model_selector: Entity, profile_selector: Entity, edits_expanded: bool, @@ -69,7 +67,6 @@ impl MessageEditor { cx: &mut Context, ) -> Self { let context_picker_menu_handle = PopoverMenuHandle::default(); - let inline_context_picker_menu_handle = PopoverMenuHandle::default(); let model_selector_menu_handle = PopoverMenuHandle::default(); let language = Language::new( @@ -112,17 +109,6 @@ impl MessageEditor { )))); }); - let inline_context_picker = cx.new(|cx| { - ContextPicker::new( - workspace.clone(), - Some(thread_store.clone()), - context_store.downgrade(), - ConfirmBehavior::Close, - window, - cx, - ) - }); - let context_strip = cx.new(|cx| { ContextStrip::new( context_store.clone(), @@ -135,14 +121,8 @@ impl MessageEditor { ) }); - let subscriptions = vec![ - cx.subscribe_in( - &inline_context_picker, - window, - Self::handle_inline_context_picker_event, - ), - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event), - ]; + let subscriptions = + vec![cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event)]; Self { editor: editor.clone(), @@ -152,8 +132,6 @@ impl MessageEditor { context_store, context_strip, context_picker_menu_handle, - inline_context_picker, - inline_context_picker_menu_handle, model_selector: cx.new(|cx| { AssistantModelSelector::new( fs.clone(), @@ -316,17 +294,6 @@ impl MessageEditor { .detach(); } - fn handle_inline_context_picker_event( - &mut self, - _inline_context_picker: &Entity, - _event: &DismissEvent, - window: &mut Window, - cx: &mut Context, - ) { - let editor_focus_handle = self.editor.focus_handle(cx); - window.focus(&editor_focus_handle); - } - fn handle_context_strip_event( &mut self, _context_strip: &Entity, @@ -346,9 +313,7 @@ impl MessageEditor { } fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { - if self.context_picker_menu_handle.is_deployed() - || self.inline_context_picker_menu_handle.is_deployed() - { + if self.context_picker_menu_handle.is_deployed() { cx.propagate(); } else { self.context_strip.focus_handle(cx).focus(window); @@ -385,8 +350,6 @@ impl Render for MessageEditor { let line_height = font_size.to_pixels(window.rem_size()) * 1.5; let focus_handle = self.editor.focus_handle(cx); - let focus_handle_clone = focus_handle.clone(); - let inline_context_picker = self.inline_context_picker.clone(); let is_editor_expanded = self.editor_is_expanded; let expand_icon = if is_editor_expanded { @@ -716,8 +679,9 @@ impl Render for MessageEditor { IconButton::new("toggle-height", expand_icon) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) - .tooltip(move |window, cx| { + .tooltip({ let focus_handle = focus_handle.clone(); + move |window, cx| { let expand_label = if is_editor_expanded { "Minimize Message Editor".to_string() } else { @@ -731,7 +695,7 @@ impl Render for MessageEditor { window, cx, ) - }) + }}) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(Box::new(ExpandMessageEditor), cx); })) @@ -766,23 +730,6 @@ impl Render for MessageEditor { }, ).into_any() })) - .child( - PopoverMenu::new("inline-context-picker") - .menu(move |window, cx| { - inline_context_picker.update(cx, |this, cx| { - this.init(window, cx); - }); - Some(inline_context_picker.clone()) - }) - .attach(gpui::Corner::TopLeft) - .anchor(gpui::Corner::BottomLeft) - .offset(gpui::Point { - x: px(0.0), - y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2) - - px(4.0), - }) - .with_handle(self.inline_context_picker_menu_handle.clone()), - ) .child( h_flex() .flex_none() @@ -791,7 +738,9 @@ impl Render for MessageEditor { .child( h_flex().gap_1() .child(self.model_selector.clone()) - .map(move |parent| { + .map({ + let focus_handle = focus_handle.clone(); + move |parent| { if is_generating { parent.child( IconButton::new("stop-generation", IconName::StopFilled) @@ -806,7 +755,7 @@ impl Render for MessageEditor { ) }) .on_click({ - let focus_handle = focus_handle_clone.clone(); + let focus_handle = focus_handle.clone(); move |_event, window, cx| { focus_handle.dispatch_action( &editor::actions::Cancel, @@ -834,7 +783,7 @@ impl Render for MessageEditor { || self.waiting_for_summaries_to_send ) .on_click({ - let focus_handle = focus_handle_clone.clone(); + let focus_handle = focus_handle.clone(); move |_event, window, cx| { focus_handle.dispatch_action(&Chat, window, cx); } @@ -861,7 +810,9 @@ impl Render for MessageEditor { }) ) } - }) + } + } + ) ), ), ) From 62ebae96e3bdbc3c5421d08f3c2a9d87670a5763 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 11 Apr 2025 17:55:23 -0600 Subject: [PATCH 21/75] agent: Only show recommended models that are actually configured (#28613) Release Notes: - N/A --- .../src/language_model_selector.rs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/language_model_selector/src/language_model_selector.rs b/crates/language_model_selector/src/language_model_selector.rs index 7f18b4d9fd..c7b5d9cd48 100644 --- a/crates/language_model_selector/src/language_model_selector.rs +++ b/crates/language_model_selector/src/language_model_selector.rs @@ -396,26 +396,33 @@ impl PickerDelegate for LanguageModelPickerDelegate { cx.spawn_in(window, async move |this, cx| { let filtered_models = cx .background_spawn(async move { - let filter_models = |model_infos: &[ModelInfo]| { - model_infos - .iter() - .filter(|model_info| { - model_info - .model - .name() - .0 - .to_lowercase() - .contains(&query.to_lowercase()) - }) - .cloned() - .collect::>() + let matches = |info: &ModelInfo| { + info.model + .name() + .0 + .to_lowercase() + .contains(&query.to_lowercase()) }; - let recommended_models = filter_models(&all_models.recommended); + let recommended_models = all_models + .recommended + .iter() + .filter(|r| { + configured_providers.contains(&r.model.provider_id()) && matches(r) + }) + .cloned() + .collect(); let mut other_models = IndexMap::default(); for (provider_id, models) in &all_models.other { if configured_providers.contains(&provider_id) { - other_models.insert(provider_id.clone(), filter_models(models)); + other_models.insert( + provider_id.clone(), + models + .iter() + .filter(|m| matches(m)) + .cloned() + .collect::>(), + ); } } GroupedModels { From 429d4580cf2fa85fd05540d26b59d2fcb96929f8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 11 Apr 2025 18:06:49 -0600 Subject: [PATCH 22/75] agent: Cleanup `message_editor` (#28614) This PR splits up the rendering of the message editor into multiple functions. Previously we had a single `h_flex()...` expression which spanned across 550 lines, `cargo fmt` stopped working. Release Notes: - N/A --- crates/agent/src/message_editor.rs | 961 +++++++++++++++-------------- 1 file changed, 481 insertions(+), 480 deletions(-) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 95870897f1..acc23dce44 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -1,6 +1,8 @@ +use std::collections::BTreeMap; use std::sync::Arc; use crate::assistant_model_selector::ModelType; +use buffer_diff::BufferDiff; use collections::HashSet; use editor::actions::MoveUp; use editor::{ @@ -336,6 +338,476 @@ impl MessageEditor { diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx)); } } + + fn render_editor( + &self, + font_size: Rems, + line_height: Pixels, + window: &mut Window, + cx: &mut Context, + ) -> Div { + let thread = self.thread.read(cx); + + let editor_bg_color = cx.theme().colors().editor_background; + let is_generating = thread.is_generating(); + let focus_handle = self.editor.focus_handle(cx); + + let is_model_selected = self.is_model_selected(cx); + let is_editor_empty = self.is_editor_empty(cx); + + let is_editor_expanded = self.editor_is_expanded; + let expand_icon = if is_editor_expanded { + IconName::Minimize + } else { + IconName::Maximize + }; + + v_flex() + .key_context("MessageEditor") + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { + this.profile_selector + .read(cx) + .menu_handle() + .toggle(window, cx); + })) + .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + this.model_selector + .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); + })) + .on_action(cx.listener(Self::toggle_context_picker)) + .on_action(cx.listener(Self::remove_all_context)) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::toggle_chat_mode)) + .on_action(cx.listener(Self::expand_message_editor)) + .gap_2() + .p_2() + .bg(editor_bg_color) + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .items_start() + .justify_between() + .child(self.context_strip.clone()) + .child( + IconButton::new("toggle-height", expand_icon) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + let expand_label = if is_editor_expanded { + "Minimize Message Editor".to_string() + } else { + "Expand Message Editor".to_string() + }; + + Tooltip::for_action_in( + expand_label, + &ExpandMessageEditor, + &focus_handle, + window, + cx, + ) + } + }) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action(Box::new(ExpandMessageEditor), cx); + })), + ), + ) + .child( + v_flex() + .size_full() + .gap_4() + .when(is_editor_expanded, |this| { + this.h(vh(0.8, window)).justify_between() + }) + .child(div().when(is_editor_expanded, |this| this.h_full()).child({ + let settings = ThemeSettings::get_global(cx); + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + line_height: line_height.into(), + ..Default::default() + }; + + EditorElement::new( + &self.editor, + EditorStyle { + background: editor_bg_color, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + .into_any() + })) + .child( + h_flex() + .flex_none() + .justify_between() + .child(h_flex().gap_2().child(self.profile_selector.clone())) + .child(h_flex().gap_1().child(self.model_selector.clone()).map({ + let focus_handle = focus_handle.clone(); + move |parent| { + if is_generating { + parent.child( + IconButton::new( + "stop-generation", + IconName::StopFilled, + ) + .icon_color(Color::Error) + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .tooltip(move |window, cx| { + Tooltip::for_action( + "Stop Generation", + &editor::actions::Cancel, + window, + cx, + ) + }) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_event, window, cx| { + focus_handle.dispatch_action( + &editor::actions::Cancel, + window, + cx, + ); + } + }) + .with_animation( + "pulsating-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 1.0)), + |icon_button, delta| icon_button.alpha(delta), + ), + ) + } else { + parent.child( + IconButton::new("send-message", IconName::Send) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .disabled( + is_editor_empty + || !is_model_selected + || self.waiting_for_summaries_to_send, + ) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_event, window, cx| { + focus_handle + .dispatch_action(&Chat, window, cx); + } + }) + .when( + !is_editor_empty && is_model_selected, + |button| { + button.tooltip(move |window, cx| { + Tooltip::for_action( + "Send", &Chat, window, cx, + ) + }) + }, + ) + .when(is_editor_empty, |button| { + button.tooltip(Tooltip::text( + "Type a message to submit", + )) + }) + .when(!is_model_selected, |button| { + button.tooltip(Tooltip::text( + "Select a model to continue", + )) + }), + ) + } + } + })), + ), + ) + } + + fn render_changed_buffers( + &self, + changed_buffers: &BTreeMap, Entity>, + window: &mut Window, + cx: &mut Context, + ) -> Div { + let focus_handle = self.editor.focus_handle(cx); + + let editor_bg_color = cx.theme().colors().editor_background; + let border_color = cx.theme().colors().border; + let active_color = cx.theme().colors().element_selected; + let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); + let is_edit_changes_expanded = self.edits_expanded; + + v_flex() + .mx_2() + .bg(bg_edit_files_disclosure) + .border_1() + .border_b_0() + .border_color(border_color) + .rounded_t_md() + .shadow(smallvec::smallvec![gpui::BoxShadow { + color: gpui::black().opacity(0.15), + offset: point(px(1.), px(-1.)), + blur_radius: px(3.), + spread_radius: px(0.), + }]) + .child( + h_flex() + .id("edits-container") + .cursor_pointer() + .p_1p5() + .justify_between() + .when(is_edit_changes_expanded, |this| { + this.border_b_1().border_color(border_color) + }) + .on_click( + cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)), + ) + .child( + h_flex() + .gap_1() + .child( + Disclosure::new("edits-disclosure", is_edit_changes_expanded) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.edits_expanded = !this.edits_expanded; + cx.notify(); + })), + ) + .child( + Label::new("Edits") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted)) + .child( + Label::new(format!( + "{} {}", + changed_buffers.len(), + if changed_buffers.len() == 1 { + "file" + } else { + "files" + } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + Button::new("review", "Review Changes") + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &OpenAgentDiff, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_review_click(window, cx) + })), + ), + ) + .when(is_edit_changes_expanded, |parent| { + parent.child( + v_flex().children(changed_buffers.into_iter().enumerate().flat_map( + |(index, (buffer, _diff))| { + let file = buffer.read(cx).file()?; + let path = file.path(); + + let parent_label = path.parent().and_then(|parent| { + let parent_str = parent.to_string_lossy(); + + if parent_str.is_empty() { + None + } else { + Some( + Label::new(format!( + "/{}{}", + parent_str, + std::path::MAIN_SEPARATOR_STR + )) + .color(Color::Muted) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + } + }); + + let name_label = path.file_name().map(|name| { + Label::new(name.to_string_lossy().to_string()) + .size(LabelSize::XSmall) + .buffer_font(cx) + }); + + let file_icon = FileIcons::get_icon(&path, cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + .unwrap_or_else(|| { + Icon::new(IconName::File) + .color(Color::Muted) + .size(IconSize::Small) + }); + + let hover_color = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + + let overlay_gradient = linear_gradient( + 90., + linear_color_stop(editor_bg_color, 1.), + linear_color_stop(editor_bg_color.opacity(0.2), 0.), + ); + + let overlay_gradient_hover = linear_gradient( + 90., + linear_color_stop(hover_color, 1.), + linear_color_stop(hover_color.opacity(0.2), 0.), + ); + + let element = h_flex() + .group("edited-code") + .id(("file-container", index)) + .cursor_pointer() + .relative() + .py_1() + .pl_2() + .pr_1() + .gap_2() + .justify_between() + .bg(cx.theme().colors().editor_background) + .hover(|style| style.bg(hover_color)) + .when(index + 1 < changed_buffers.len(), |parent| { + parent.border_color(border_color).border_b_1() + }) + .child( + h_flex() + .id("file-name") + .pr_8() + .gap_1p5() + .max_w_full() + .overflow_x_scroll() + .child(file_icon) + .child( + h_flex() + .gap_0p5() + .children(name_label) + .children(parent_label), + ) // TODO: show lines changed + .child(Label::new("+").color(Color::Created)) + .child(Label::new("-").color(Color::Deleted)), + ) + .child( + div().visible_on_hover("edited-code").child( + Button::new("review", "Review") + .label_size(LabelSize::Small) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.handle_file_click( + buffer.clone(), + window, + cx, + ); + }) + }), + ), + ) + .child( + div() + .id("gradient-overlay") + .absolute() + .h_5_6() + .w_12() + .bottom_0() + .right(px(52.)) + .bg(overlay_gradient) + .group_hover("edited-code", |style| { + style.bg(overlay_gradient_hover) + }), + ) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.handle_file_click(buffer.clone(), window, cx); + }) + }); + + Some(element) + }, + )), + ) + }) + } + + fn render_reaching_token_limit(&self, line_height: Pixels, cx: &mut Context) -> Div { + h_flex() + .p_2() + .gap_2() + .flex_wrap() + .justify_between() + .bg(cx.theme().status().warning_background.opacity(0.1)) + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .gap_2() + .items_start() + .child( + h_flex() + .h(line_height) + .justify_center() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall), + ), + ) + .child( + v_flex() + .mr_auto() + .child(Label::new("Thread reaching the token limit soon").size(LabelSize::Small)) + .child( + Label::new( + "Start a new thread from a summary to continue the conversation.", + ) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + .child( + Button::new("new-thread", "Start New Thread") + .on_click(cx.listener(|this, _, window, cx| { + let from_thread_id = Some(this.thread.read(cx).id().clone()); + + window.dispatch_action(Box::new(NewThread { + from_thread_id + }), cx); + })) + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .label_size(LabelSize::Small), + ) + } } impl Focusable for MessageEditor { @@ -346,33 +818,14 @@ impl Focusable for MessageEditor { impl Render for MessageEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let font_size = TextSize::Small.rems(cx); - let line_height = font_size.to_pixels(window.rem_size()) * 1.5; - - let focus_handle = self.editor.focus_handle(cx); - - let is_editor_expanded = self.editor_is_expanded; - let expand_icon = if is_editor_expanded { - IconName::Minimize - } else { - IconName::Maximize - }; - let thread = self.thread.read(cx); - let is_generating = thread.is_generating(); let total_token_usage = thread.total_token_usage(cx); - let is_model_selected = self.is_model_selected(cx); - let is_editor_empty = self.is_editor_empty(cx); - let is_edit_changes_expanded = self.edits_expanded; let action_log = self.thread.read(cx).action_log(); let changed_buffers = action_log.read(cx).changed_buffers(cx); - let changed_buffers_count = changed_buffers.len(); - let editor_bg_color = cx.theme().colors().editor_background; - let border_color = cx.theme().colors().border; - let active_color = cx.theme().colors().element_selected; - let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); + let font_size = TextSize::Small.rems(cx); + let line_height = font_size.to_pixels(window.rem_size()) * 1.5; v_flex() .size_full() @@ -383,7 +836,7 @@ impl Render for MessageEditor { .flex_none() .px_2() .py_2() - .bg(editor_bg_color) + .bg(cx.theme().colors().editor_background) .border_1() .border_color(cx.theme().colors().border_variant) .rounded_lg() @@ -411,465 +864,13 @@ impl Render for MessageEditor { ), ) }) - .when(changed_buffers_count > 0, |parent| { - parent.child( - v_flex() - .mx_2() - .bg(bg_edit_files_disclosure) - .border_1() - .border_b_0() - .border_color(border_color) - .rounded_t_md() - .shadow(smallvec::smallvec![gpui::BoxShadow { - color: gpui::black().opacity(0.15), - offset: point(px(1.), px(-1.)), - blur_radius: px(3.), - spread_radius: px(0.), - }]) - .child( - h_flex() - .id("edits-container") - .cursor_pointer() - .p_1p5() - .justify_between() - .when(is_edit_changes_expanded, |this| { - this.border_b_1().border_color(border_color) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_review_click(window, cx) - })) - .child( - h_flex() - .gap_1() - .child( - Disclosure::new( - "edits-disclosure", - is_edit_changes_expanded, - ) - .on_click( - cx.listener(|this, _ev, _window, cx| { - this.edits_expanded = !this.edits_expanded; - cx.notify(); - }), - ), - ) - .child( - Label::new("Edits") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(format!( - "{} {}", - changed_buffers_count, - if changed_buffers_count == 1 { - "file" - } else { - "files" - } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - Button::new("review", "Review Changes") - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &OpenAgentDiff, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_review_click(window, cx) - })), - ), - ) - .when(is_edit_changes_expanded, |parent| { - parent.child( - v_flex().children( - changed_buffers.into_iter().enumerate().flat_map( - |(index, (buffer, _diff))| { - let file = buffer.read(cx).file()?; - let path = file.path(); - - let parent_label = path.parent().and_then(|parent| { - let parent_str = parent.to_string_lossy(); - - if parent_str.is_empty() { - None - } else { - Some( - Label::new(format!( - "/{}{}", - parent_str, - std::path::MAIN_SEPARATOR_STR - )) - .color(Color::Muted) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - } - }); - - let name_label = path.file_name().map(|name| { - Label::new(name.to_string_lossy().to_string()) - .size(LabelSize::XSmall) - .buffer_font(cx) - }); - - let file_icon = FileIcons::get_icon(&path, cx) - .map(Icon::from_path) - .map(|icon| { - icon.color(Color::Muted).size(IconSize::Small) - }) - .unwrap_or_else(|| { - Icon::new(IconName::File) - .color(Color::Muted) - .size(IconSize::Small) - }); - - let hover_color = cx.theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let overlay_gradient = linear_gradient( - 90., - linear_color_stop( - editor_bg_color, - 1., - ), - linear_color_stop( - editor_bg_color - .opacity(0.2), - 0., - ), - ); - - let overlay_gradient_hover = linear_gradient( - 90., - linear_color_stop( - hover_color, - 1., - ), - linear_color_stop( - hover_color - .opacity(0.2), - 0., - ), - ); - - let element = h_flex() - .group("edited-code") - .id(("file-container", index)) - .cursor_pointer() - .relative() - .py_1() - .pl_2() - .pr_1() - .gap_2() - .justify_between() - .bg(cx.theme().colors().editor_background) - .hover(|style| style.bg(hover_color)) - .when(index + 1 < changed_buffers_count, |parent| { - parent.border_color(border_color).border_b_1() - }) - .child( - h_flex() - .id("file-name") - .pr_8() - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .child(file_icon) - .child( - h_flex() - .gap_0p5() - .children(name_label) - .children(parent_label) - ) // TODO: show lines changed - .child( - Label::new("+") - .color(Color::Created), - ) - .child( - Label::new("-") - .color(Color::Deleted), - ), - ) - .child( - div().visible_on_hover("edited-code").child( - Button::new("review", "Review") - .label_size(LabelSize::Small) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.handle_file_click(buffer.clone(), window, cx); - }) - }) - ) - ) - .child( - div() - .id("gradient-overlay") - .absolute() - .h_5_6() - .w_12() - .bottom_0() - .right(px(52.)) - .bg(overlay_gradient) - .group_hover("edited-code", |style| style.bg(overlay_gradient_hover)) - , - ) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.handle_file_click(buffer.clone(), window, cx); - }) - }); - - Some(element) - }, - ), - ), - ) - }), - ) + .when(changed_buffers.len() > 0, |parent| { + parent.child(self.render_changed_buffers(&changed_buffers, window, cx)) }) - .child( - v_flex() - .key_context("MessageEditor") - .on_action(cx.listener(Self::chat)) - .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { - this.profile_selector - .read(cx) - .menu_handle() - .toggle(window, cx); - })) - .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { - this.model_selector - .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); - })) - .on_action(cx.listener(Self::toggle_context_picker)) - .on_action(cx.listener(Self::remove_all_context)) - .on_action(cx.listener(Self::move_up)) - .on_action(cx.listener(Self::toggle_chat_mode)) - .on_action(cx.listener(Self::expand_message_editor)) - .gap_2() - .p_2() - .bg(editor_bg_color) - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .items_start() - .justify_between() - .child(self.context_strip.clone()) - .child( - IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - let expand_label = if is_editor_expanded { - "Minimize Message Editor".to_string() - } else { - "Expand Message Editor".to_string() - }; - - Tooltip::for_action_in( - expand_label, - &ExpandMessageEditor, - &focus_handle, - window, - cx, - ) - }}) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(ExpandMessageEditor), cx); - })) - ) - ) - .child( - v_flex() - .size_full() - .gap_4() - .when(is_editor_expanded, |this| this.h(vh(0.8, window)).justify_between()) - .child(div().when(is_editor_expanded, |this| this.h_full()).child({ - let settings = ThemeSettings::get_global(cx); - - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - line_height: line_height.into(), - ..Default::default() - }; - - EditorElement::new( - &self.editor, - EditorStyle { - background: editor_bg_color, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - ..Default::default() - }, - ).into_any() - })) - .child( - h_flex() - .flex_none() - .justify_between() - .child(h_flex().gap_2().child(self.profile_selector.clone())) - .child( - h_flex().gap_1() - .child(self.model_selector.clone()) - .map({ - let focus_handle = focus_handle.clone(); - move |parent| { - if is_generating { - parent.child( - IconButton::new("stop-generation", IconName::StopFilled) - .icon_color(Color::Error) - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .tooltip(move |window, cx| { - Tooltip::for_action( - "Stop Generation", - &editor::actions::Cancel, - window, - cx, - ) - }) - .on_click({ - let focus_handle = focus_handle.clone(); - move |_event, window, cx| { - focus_handle.dispatch_action( - &editor::actions::Cancel, - window, - cx, - ); - } - }) - .with_animation( - "pulsating-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 1.0)), - |icon_button, delta| icon_button.alpha(delta), - ), - ) - } else { - parent.child( - IconButton::new("send-message", IconName::Send) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .disabled( - is_editor_empty - || !is_model_selected - || self.waiting_for_summaries_to_send - ) - .on_click({ - let focus_handle = focus_handle.clone(); - move |_event, window, cx| { - focus_handle.dispatch_action(&Chat, window, cx); - } - }) - .when(!is_editor_empty && is_model_selected, |button| { - button.tooltip(move |window, cx| { - Tooltip::for_action( - "Send", - &Chat, - window, - cx, - ) - }) - }) - .when(is_editor_empty, |button| { - button.tooltip(Tooltip::text( - "Type a message to submit", - )) - }) - .when(!is_model_selected, |button| { - button.tooltip(Tooltip::text( - "Select a model to continue", - )) - }) - ) - } - } - } - ) - ), - ), - ) + .child(self.render_editor(font_size, line_height, window, cx)) + .when( + total_token_usage.ratio != TokenUsageRatio::Normal, + |parent| parent.child(self.render_reaching_token_limit(line_height, cx)), ) - .when(total_token_usage.ratio != TokenUsageRatio::Normal, |parent| { - parent.child( - h_flex() - .p_2() - .gap_2() - .flex_wrap() - .justify_between() - .bg(cx.theme().status().warning_background.opacity(0.1)) - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .gap_2() - .items_start() - .child( - h_flex() - .h(line_height) - .justify_center() - .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall), - ), - ) - .child( - v_flex() - .mr_auto() - .child(Label::new("Thread reaching the token limit soon").size(LabelSize::Small)) - .child( - Label::new( - "Start a new thread from a summary to continue the conversation.", - ) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - .child( - Button::new("new-thread", "Start New Thread") - .on_click(cx.listener(|this, _, window, cx| { - let from_thread_id = Some(this.thread.read(cx).id().clone()); - - window.dispatch_action(Box::new(NewThread { - from_thread_id - }), cx); - })) - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .label_size(LabelSize::Small), - ), - ) - }) } } From 055df307571af95bd91968c92a2e7024fcd2e650 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 11 Apr 2025 20:35:14 -0400 Subject: [PATCH 23/75] Directly parse .git when it's a file instead of using libgit2 (#27885) Avoids building a whole git2 repository object at the worktree layer just to watch some additional paths. - [x] Tidy up names of the various paths - [x] Tests for worktrees and submodules Release Notes: - N/A --- crates/fs/src/fake_git_repo.rs | 22 ++-- crates/fs/src/fs.rs | 163 +++++++++++++++++++------ crates/git/src/repository.rs | 6 - crates/project/src/git_store.rs | 33 +++-- crates/project/src/project.rs | 26 ++++ crates/project/src/project_tests.rs | 180 +++++++++++++++++++++------- crates/worktree/src/worktree.rs | 131 ++++++++++++-------- 7 files changed, 401 insertions(+), 160 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 584abd4cf7..15750bfccc 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -21,11 +21,12 @@ pub struct FakeGitRepository { pub(crate) fs: Arc, pub(crate) executor: BackgroundExecutor, pub(crate) dot_git_path: PathBuf, + pub(crate) repository_dir_path: PathBuf, + pub(crate) common_dir_path: PathBuf, } #[derive(Debug, Clone)] pub struct FakeGitRepositoryState { - pub path: PathBuf, pub event_emitter: smol::channel::Sender, pub unmerged_paths: HashMap, pub head_contents: HashMap, @@ -37,9 +38,8 @@ pub struct FakeGitRepositoryState { } impl FakeGitRepositoryState { - pub fn new(path: PathBuf, event_emitter: smol::channel::Sender) -> Self { + pub fn new(event_emitter: smol::channel::Sender) -> Self { FakeGitRepositoryState { - path, event_emitter, head_contents: Default::default(), index_contents: Default::default(), @@ -53,15 +53,6 @@ impl FakeGitRepositoryState { } impl FakeGitRepository { - fn with_state(&self, f: F) -> T - where - F: FnOnce(&mut FakeGitRepositoryState) -> T, - { - self.fs - .with_git_state(&self.dot_git_path, false, f) - .unwrap() - } - fn with_state_async(&self, write: bool, f: F) -> BoxFuture<'static, Result> where F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result, @@ -172,11 +163,11 @@ impl GitRepository for FakeGitRepository { } fn path(&self) -> PathBuf { - self.with_state(|state| state.path.clone()) + self.repository_dir_path.clone() } fn main_repository_path(&self) -> PathBuf { - self.path() + self.common_dir_path.clone() } fn merge_message(&self) -> BoxFuture> { @@ -207,8 +198,9 @@ impl GitRepository for FakeGitRepository { .files() .iter() .filter_map(|path| { + // TODO better simulate git status output in the case of submodules and worktrees let repo_path = path.strip_prefix(workdir_path).ok()?; - let mut is_ignored = false; + let mut is_ignored = repo_path.starts_with(".git"); for ignore in &ignores { match ignore.matched_path_or_any_parents(path, false) { ignore::Match::None => {} diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 272a05e9b8..bc60e8a2fd 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -851,7 +851,7 @@ impl Watcher for RealWatcher { pub struct FakeFs { this: std::sync::Weak, // Use an unfair lock to ensure tests are deterministic. - state: Mutex, + state: Arc>, executor: gpui::BackgroundExecutor, } @@ -878,6 +878,8 @@ enum FakeFsEntry { mtime: MTime, len: u64, content: Vec, + // The path to the repository state directory, if this is a gitfile. + git_dir_path: Option, }, Dir { inode: u64, @@ -1036,7 +1038,7 @@ impl FakeFs { let this = Arc::new_cyclic(|this| Self { this: this.clone(), executor: executor.clone(), - state: Mutex::new(FakeFsState { + state: Arc::new(Mutex::new(FakeFsState { root: Arc::new(Mutex::new(FakeFsEntry::Dir { inode: 0, mtime: MTime(UNIX_EPOCH), @@ -1054,7 +1056,7 @@ impl FakeFs { metadata_call_count: 0, moves: Default::default(), home_dir: None, - }), + })), }); executor.spawn({ @@ -1097,6 +1099,7 @@ impl FakeFs { mtime: new_mtime, content: Vec::new(), len: 0, + git_dir_path: None, }))); } btree_map::Entry::Occupied(mut e) => match &mut *e.get_mut().lock() { @@ -1154,6 +1157,7 @@ impl FakeFs { mtime: new_mtime, len: new_len, content: new_content, + git_dir_path: None, }))); } btree_map::Entry::Occupied(mut e) => { @@ -1278,9 +1282,14 @@ impl FakeFs { .boxed() } - pub fn with_git_state(&self, dot_git: &Path, emit_git_event: bool, f: F) -> Result + pub fn with_git_state_and_paths( + &self, + dot_git: &Path, + emit_git_event: bool, + f: F, + ) -> Result where - F: FnOnce(&mut FakeGitRepositoryState) -> T, + F: FnOnce(&mut FakeGitRepositoryState, &Path, &Path) -> T, { let mut state = self.state.lock(); let entry = state.read_path(dot_git).context("open .git")?; @@ -1288,25 +1297,75 @@ impl FakeFs { if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry { let repo_state = git_repo_state.get_or_insert_with(|| { + log::debug!("insert git state for {dot_git:?}"); Arc::new(Mutex::new(FakeGitRepositoryState::new( - dot_git.to_path_buf(), state.git_event_tx.clone(), ))) }); let mut repo_state = repo_state.lock(); - let result = f(&mut repo_state); + let result = f(&mut repo_state, dot_git, dot_git); if emit_git_event { state.emit_event([(dot_git, None)]); } + Ok(result) + } else if let FakeFsEntry::File { + content, + git_dir_path, + .. + } = &mut *entry + { + let path = match git_dir_path { + Some(path) => path, + None => { + let path = std::str::from_utf8(content) + .ok() + .and_then(|content| content.strip_prefix("gitdir:")) + .ok_or_else(|| anyhow!("not a valid gitfile"))? + .trim(); + git_dir_path.insert(normalize_path(&dot_git.parent().unwrap().join(path))) + } + } + .clone(); + drop(entry); + let Some((git_dir_entry, canonical_path)) = state.try_read_path(&path, true) else { + anyhow::bail!("pointed-to git dir {path:?} not found") + }; + let FakeFsEntry::Dir { git_repo_state, .. } = &mut *git_dir_entry.lock() else { + anyhow::bail!("gitfile points to a non-directory") + }; + let common_dir = canonical_path + .ancestors() + .find(|ancestor| ancestor.ends_with(".git")) + .ok_or_else(|| anyhow!("repository dir not contained in any .git"))?; + let repo_state = git_repo_state.get_or_insert_with(|| { + Arc::new(Mutex::new(FakeGitRepositoryState::new( + state.git_event_tx.clone(), + ))) + }); + let mut repo_state = repo_state.lock(); + + let result = f(&mut repo_state, &canonical_path, common_dir); + + if emit_git_event { + state.emit_event([(canonical_path, None)]); + } + Ok(result) } else { - Err(anyhow!("not a directory")) + Err(anyhow!("not a valid git repository")) } } + pub fn with_git_state(&self, dot_git: &Path, emit_git_event: bool, f: F) -> Result + where + F: FnOnce(&mut FakeGitRepositoryState) -> T, + { + self.with_git_state_and_paths(dot_git, emit_git_event, |state, _, _| f(state)) + } + pub fn set_branch_name(&self, dot_git: &Path, branch: Option>) { self.with_git_state(dot_git, true, |state| { let branch = branch.map(Into::into); @@ -1663,11 +1722,25 @@ impl FakeFsEntry { } #[cfg(any(test, feature = "test-support"))] -struct FakeWatcher {} +struct FakeWatcher { + tx: smol::channel::Sender>, + original_path: PathBuf, + fs_state: Arc>, + prefixes: Mutex>, +} #[cfg(any(test, feature = "test-support"))] impl Watcher for FakeWatcher { - fn add(&self, _: &Path) -> Result<()> { + fn add(&self, path: &Path) -> Result<()> { + if path.starts_with(&self.original_path) { + return Ok(()); + } + self.fs_state + .try_lock() + .unwrap() + .event_txs + .push((path.to_owned(), self.tx.clone())); + self.prefixes.lock().push(path.to_owned()); Ok(()) } @@ -1745,6 +1818,7 @@ impl Fs for FakeFs { mtime, len: 0, content: Vec::new(), + git_dir_path: None, })); let mut kind = Some(PathEventKind::Created); state.write_path(path, |entry| { @@ -1901,6 +1975,7 @@ impl Fs for FakeFs { mtime, len: content.len() as u64, content, + git_dir_path: None, }))) .clone(), )), @@ -2137,42 +2212,54 @@ impl Fs for FakeFs { self.simulate_random_delay().await; let (tx, rx) = smol::channel::unbounded(); let path = path.to_path_buf(); - self.state.lock().event_txs.push((path.clone(), tx)); + self.state.lock().event_txs.push((path.clone(), tx.clone())); let executor = self.executor.clone(); + let watcher = Arc::new(FakeWatcher { + tx, + original_path: path.to_owned(), + fs_state: self.state.clone(), + prefixes: Mutex::new(vec![path.to_owned()]), + }); ( - Box::pin(futures::StreamExt::filter(rx, move |events| { - let result = events - .iter() - .any(|evt_path| evt_path.path.starts_with(&path)); - let executor = executor.clone(); - async move { - executor.simulate_random_delay().await; - result + Box::pin(futures::StreamExt::filter(rx, { + let watcher = watcher.clone(); + move |events| { + let result = events.iter().any(|evt_path| { + let result = watcher + .prefixes + .lock() + .iter() + .any(|prefix| evt_path.path.starts_with(prefix)); + result + }); + let executor = executor.clone(); + async move { + executor.simulate_random_delay().await; + result + } } })), - Arc::new(FakeWatcher {}), + watcher, ) } fn open_repo(&self, abs_dot_git: &Path) -> Option> { - let state = self.state.lock(); - let entry = state.read_path(abs_dot_git).unwrap(); - let mut entry = entry.lock(); - if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry { - git_repo_state.get_or_insert_with(|| { - Arc::new(Mutex::new(FakeGitRepositoryState::new( - abs_dot_git.to_path_buf(), - state.git_event_tx.clone(), - ))) - }); - Some(Arc::new(fake_git_repo::FakeGitRepository { - fs: self.this.upgrade().unwrap(), - executor: self.executor.clone(), - dot_git_path: abs_dot_git.to_path_buf(), - })) - } else { - None - } + use util::ResultExt as _; + + self.with_git_state_and_paths( + abs_dot_git, + false, + |_, repository_dir_path, common_dir_path| { + Arc::new(fake_git_repo::FakeGitRepository { + fs: self.this.upgrade().unwrap(), + executor: self.executor.clone(), + dot_git_path: abs_dot_git.to_path_buf(), + repository_dir_path: repository_dir_path.to_owned(), + common_dir_path: common_dir_path.to_owned(), + }) as _ + }, + ) + .log_err() } fn git_init( diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index da68a532e3..060c14ffcc 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -229,12 +229,6 @@ pub trait GitRepository: Send + Sync { /// worktree's gitdir within the main repository (typically `.git/worktrees/`). fn path(&self) -> PathBuf; - /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git` - /// folder. For worktrees, this will be the path to the repository the worktree was created - /// from. Otherwise, this is the same value as `path()`. - /// - /// Git documentation calls this the "commondir", and for git CLI is overridden by - /// `GIT_COMMON_DIR`. fn main_repository_path(&self) -> PathBuf; /// Updates the index to match the worktree at the given paths. diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index b0b35c52bc..b917295ec1 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -61,7 +61,8 @@ use sum_tree::{Edit, SumTree, TreeSet}; use text::{Bias, BufferId}; use util::{ResultExt, debug_panic, post_inc}; use worktree::{ - File, PathKey, PathProgress, PathSummary, PathTarget, UpdatedGitRepositoriesSet, Worktree, + File, PathKey, PathProgress, PathSummary, PathTarget, UpdatedGitRepositoriesSet, + UpdatedGitRepository, Worktree, }; pub struct GitStore { @@ -1144,18 +1145,23 @@ impl GitStore { } else { removed_ids.push(*id); } - } else if let Some((work_directory_abs_path, dot_git_abs_path)) = update - .new_work_directory_abs_path - .clone() - .zip(update.dot_git_abs_path.clone()) + } else if let UpdatedGitRepository { + new_work_directory_abs_path: Some(work_directory_abs_path), + dot_git_abs_path: Some(dot_git_abs_path), + repository_dir_abs_path: Some(repository_dir_abs_path), + common_dir_abs_path: Some(common_dir_abs_path), + .. + } = update { let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); let git_store = cx.weak_entity(); let repo = cx.new(|cx| { let mut repo = Repository::local( id, - work_directory_abs_path, - dot_git_abs_path, + work_directory_abs_path.clone(), + dot_git_abs_path.clone(), + repository_dir_abs_path.clone(), + common_dir_abs_path.clone(), project_environment.downgrade(), fs.clone(), git_store, @@ -2542,6 +2548,8 @@ impl Repository { id: RepositoryId, work_directory_abs_path: Arc, dot_git_abs_path: Arc, + repository_dir_abs_path: Arc, + common_dir_abs_path: Arc, project_environment: WeakEntity, fs: Arc, git_store: WeakEntity, @@ -2559,6 +2567,8 @@ impl Repository { job_sender: Repository::spawn_local_git_worker( work_directory_abs_path, dot_git_abs_path, + repository_dir_abs_path, + common_dir_abs_path, project_environment, fs, cx, @@ -3836,6 +3846,8 @@ impl Repository { fn spawn_local_git_worker( work_directory_abs_path: Arc, dot_git_abs_path: Arc, + repository_dir_abs_path: Arc, + common_dir_abs_path: Arc, project_environment: WeakEntity, fs: Arc, cx: &mut Context, @@ -3861,6 +3873,9 @@ impl Repository { }) .await?; + debug_assert_eq!(backend.path().as_path(), repository_dir_abs_path.as_ref()); + debug_assert_eq!(backend.main_repository_path().as_path(), common_dir_abs_path.as_ref()); + if let Some(git_hosting_provider_registry) = cx.update(|cx| GitHostingProviderRegistry::try_global(cx))? { @@ -4092,6 +4107,10 @@ impl Repository { pub fn current_job(&self) -> Option { self.active_jobs.values().next().cloned() } + + pub fn barrier(&mut self) -> oneshot::Receiver<()> { + self.send_job(None, |_, _| async {}) + } } fn get_permalink_in_rust_registry_src( diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7ce0d92379..d27ffc070c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -48,6 +48,8 @@ use debugger::{ session::Session, }; pub use environment::ProjectEnvironment; +#[cfg(test)] +use futures::future::join_all; use futures::{ StreamExt, channel::mpsc::{self, UnboundedReceiver}, @@ -4808,6 +4810,30 @@ impl Project { &self.git_store } + #[cfg(test)] + fn git_scans_complete(&self, cx: &Context) -> Task<()> { + cx.spawn(async move |this, cx| { + let scans_complete = this + .read_with(cx, |this, cx| { + this.worktrees(cx) + .filter_map(|worktree| Some(worktree.read(cx).as_local()?.scan_complete())) + .collect::>() + }) + .unwrap(); + join_all(scans_complete).await; + let barriers = this + .update(cx, |this, cx| { + let repos = this.repositories(cx).values().cloned().collect::>(); + repos + .into_iter() + .map(|repo| repo.update(cx, |repo, _| repo.barrier())) + .collect::>() + }) + .unwrap(); + join_all(barriers).await; + }) + } + pub fn active_repository(&self, cx: &App) -> Option> { self.git_store.read(cx).active_repository() } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 93537e4e46..fd1106b11d 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -7192,7 +7192,7 @@ async fn test_repository_and_path_for_project_path( let tree_id = tree.read_with(cx, |tree, _| tree.id()); tree.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) .await; - tree.flush_fs_events(cx).await; + cx.run_until_parked(); project.read_with(cx, |project, cx| { let git_store = project.git_store().read(cx); @@ -7233,7 +7233,7 @@ async fn test_repository_and_path_for_project_path( fs.remove_dir(path!("/root/dir1/.git").as_ref(), RemoveOptions::default()) .await .unwrap(); - tree.flush_fs_events(cx).await; + cx.run_until_parked(); project.read_with(cx, |project, cx| { let git_store = project.git_store().read(cx); @@ -7493,49 +7493,51 @@ async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_repository_subfolder_git_status(cx: &mut gpui::TestAppContext) { +async fn test_repository_subfolder_git_status( + executor: gpui::BackgroundExecutor, + cx: &mut gpui::TestAppContext, +) { init_test(cx); - cx.executor().allow_parking(); - let root = TempTree::new(json!({ - "my-repo": { - // .git folder will go here - "a.txt": "a", - "sub-folder-1": { - "sub-folder-2": { - "c.txt": "cc", - "d": { - "e.txt": "eee" - } - }, - } - }, - })); + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/root"), + json!({ + "my-repo": { + ".git": {}, + "a.txt": "a", + "sub-folder-1": { + "sub-folder-2": { + "c.txt": "cc", + "d": { + "e.txt": "eee" + } + }, + } + }, + }), + ) + .await; const C_TXT: &str = "sub-folder-1/sub-folder-2/c.txt"; const E_TXT: &str = "sub-folder-1/sub-folder-2/d/e.txt"; - // Set up git repository before creating the worktree. - let git_repo_work_dir = root.path().join("my-repo"); - let repo = git_init(git_repo_work_dir.as_path()); - git_add(C_TXT, &repo); - git_commit("Initial commit", &repo); - - // Open the worktree in subfolder - let project_root = Path::new("my-repo/sub-folder-1/sub-folder-2"); + fs.set_status_for_repo( + path!("/root/my-repo/.git").as_ref(), + &[(E_TXT.as_ref(), FileStatus::Untracked)], + ); let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path().join(project_root).as_path()], + fs.clone(), + [path!("/root/my-repo/sub-folder-1/sub-folder-2").as_ref()], cx, ) .await; - let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); - tree.flush_fs_events(cx).await; - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + project + .update(cx, |project, cx| project.git_scans_complete(cx)) .await; - cx.executor().run_until_parked(); + cx.run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() @@ -7544,8 +7546,8 @@ async fn test_repository_subfolder_git_status(cx: &mut gpui::TestAppContext) { // Ensure that the git status is loaded correctly repository.read_with(cx, |repository, _cx| { assert_eq!( - repository.work_directory_abs_path.canonicalize().unwrap(), - root.path().join("my-repo").canonicalize().unwrap() + repository.work_directory_abs_path, + Path::new(path!("/root/my-repo")).into() ); assert_eq!(repository.status_for_path(&C_TXT.into()), None); @@ -7555,13 +7557,11 @@ async fn test_repository_subfolder_git_status(cx: &mut gpui::TestAppContext) { ); }); - // Now we simulate FS events, but ONLY in the .git folder that's outside - // of out project root. - // Meaning: we don't produce any FS events for files inside the project. - git_add(E_TXT, &repo); - git_commit("Second commit", &repo); - tree.flush_fs_events_in_root_git_repository(cx).await; - cx.executor().run_until_parked(); + fs.set_status_for_repo(path!("/root/my-repo/.git").as_ref(), &[]); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); repository.read_with(cx, |repository, _cx| { assert_eq!(repository.status_for_path(&C_TXT.into()), None); @@ -8182,6 +8182,104 @@ async fn test_rescan_with_gitignore(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_git_worktrees_and_submodules(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": { + "worktrees": { + "some-worktree": {} + }, + }, + "src": { + "a.txt": "A", + }, + "some-worktree": { + ".git": "gitdir: ../.git/worktrees/some-worktree", + "src": { + "b.txt": "B", + } + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let scan_complete = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }); + scan_complete.await; + + let mut repositories = project.update(cx, |project, cx| { + project + .repositories(cx) + .values() + .map(|repo| repo.read(cx).work_directory_abs_path.clone()) + .collect::>() + }); + repositories.sort(); + pretty_assertions::assert_eq!( + repositories, + [ + Path::new(path!("/project")).into(), + Path::new(path!("/project/some-worktree")).into(), + ] + ); + + fs.with_git_state( + path!("/project/some-worktree/.git").as_ref(), + true, + |state| { + state + .head_contents + .insert("src/b.txt".into(), "b".to_owned()); + state + .index_contents + .insert("src/b.txt".into(), "b".to_owned()); + }, + ) + .unwrap(); + cx.run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/some-worktree/src/b.txt"), cx) + }) + .await + .unwrap(); + let (worktree_repo, barrier) = project.update(cx, |project, cx| { + let (repo, _) = project + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + .unwrap(); + pretty_assertions::assert_eq!( + repo.read(cx).work_directory_abs_path, + Path::new(path!("/project/some-worktree")).into(), + ); + let barrier = repo.update(cx, |repo, _| repo.barrier()); + (repo.clone(), barrier) + }); + barrier.await.unwrap(); + worktree_repo.update(cx, |repo, _| { + pretty_assertions::assert_eq!( + repo.status_for_path(&"src/b.txt".into()).unwrap().status, + StatusCode::Modified.worktree(), + ); + }); +} + #[gpui::test] async fn test_repository_deduplication(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 920b89770b..eff02a5cfa 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -384,12 +384,23 @@ struct LocalRepositoryEntry { work_directory: WorkDirectory, work_directory_abs_path: Arc, git_dir_scan_id: usize, - original_dot_git_abs_path: Arc, - /// Absolute path to the actual .git folder. - /// Note: if .git is a file, this points to the folder indicated by the .git file - dot_git_dir_abs_path: Arc, - /// Absolute path to the .git file, if we're in a git worktree. - dot_git_worktree_abs_path: Option>, + /// Absolute path to the original .git entry that caused us to create this repository. + /// + /// This is normally a directory, but may be a "gitfile" that points to a directory elsewhere + /// (whose path we then store in `repository_dir_abs_path`). + dot_git_abs_path: Arc, + /// Absolute path to the "commondir" for this repository. + /// + /// This is always a directory. For a normal repository, this is the same as dot_git_abs_path, + /// but in the case of a submodule or a worktree it is the path to the "parent" .git directory + /// from which the submodule/worktree was derived. + common_dir_abs_path: Arc, + /// Absolute path to the directory holding the repository's state. + /// + /// For a normal repository, this is a directory and coincides with `dot_git_abs_path` and + /// `common_dir_abs_path`. For a submodule or worktree, this is some subdirectory of the + /// commondir like `/project/.git/modules/foo`. + repository_dir_abs_path: Arc, } impl sum_tree::Item for LocalRepositoryEntry { @@ -1351,7 +1362,11 @@ impl LocalWorktree { new_work_directory_abs_path: Some( new_repo.work_directory_abs_path.clone(), ), - dot_git_abs_path: Some(new_repo.original_dot_git_abs_path.clone()), + dot_git_abs_path: Some(new_repo.dot_git_abs_path.clone()), + repository_dir_abs_path: Some( + new_repo.repository_dir_abs_path.clone(), + ), + common_dir_abs_path: Some(new_repo.common_dir_abs_path.clone()), }); new_repos.next(); } @@ -1368,9 +1383,11 @@ impl LocalWorktree { new_work_directory_abs_path: Some( new_repo.work_directory_abs_path.clone(), ), - dot_git_abs_path: Some( - new_repo.original_dot_git_abs_path.clone(), + dot_git_abs_path: Some(new_repo.dot_git_abs_path.clone()), + repository_dir_abs_path: Some( + new_repo.repository_dir_abs_path.clone(), ), + common_dir_abs_path: Some(new_repo.common_dir_abs_path.clone()), }); } new_repos.next(); @@ -1384,6 +1401,8 @@ impl LocalWorktree { ), new_work_directory_abs_path: None, dot_git_abs_path: None, + repository_dir_abs_path: None, + common_dir_abs_path: None, }); old_repos.next(); } @@ -1394,7 +1413,9 @@ impl LocalWorktree { work_directory_id: entry_id, old_work_directory_abs_path: None, new_work_directory_abs_path: Some(repo.work_directory_abs_path.clone()), - dot_git_abs_path: Some(repo.original_dot_git_abs_path.clone()), + dot_git_abs_path: Some(repo.dot_git_abs_path.clone()), + repository_dir_abs_path: Some(repo.repository_dir_abs_path.clone()), + common_dir_abs_path: Some(repo.common_dir_abs_path.clone()), }); new_repos.next(); } @@ -1403,7 +1424,9 @@ impl LocalWorktree { work_directory_id: entry_id, old_work_directory_abs_path: Some(repo.work_directory_abs_path.clone()), new_work_directory_abs_path: None, - dot_git_abs_path: Some(repo.original_dot_git_abs_path.clone()), + dot_git_abs_path: Some(repo.dot_git_abs_path.clone()), + repository_dir_abs_path: Some(repo.repository_dir_abs_path.clone()), + common_dir_abs_path: Some(repo.common_dir_abs_path.clone()), }); old_repos.next(); } @@ -3042,9 +3065,6 @@ impl BackgroundScannerState { ); return; }; - log::debug!( - "building git repository, `.git` path in the worktree: {dot_git_path:?}" - ); parent_dir.into() } @@ -3075,7 +3095,6 @@ impl BackgroundScannerState { fs: &dyn Fs, watcher: &dyn Watcher, ) -> Option { - log::trace!("insert git repository for {dot_git_path:?}"); let work_dir_entry = self.snapshot.entry_for_path(work_directory.path_key().0)?; let work_directory_abs_path = self .snapshot @@ -3092,46 +3111,51 @@ impl BackgroundScannerState { return None; } - let dot_git_abs_path = self.snapshot.abs_path.as_path().join(&dot_git_path); + let dot_git_abs_path: Arc = self + .snapshot + .abs_path + .as_path() + .join(&dot_git_path) + .as_path() + .into(); - // TODO add these watchers without building a whole repository by parsing .git-with-indirection - let t0 = Instant::now(); - let repository = fs.open_repo(&dot_git_abs_path)?; - log::trace!("opened git repo for {dot_git_abs_path:?}"); - - let repository_path = repository.path(); - watcher.add(&repository_path).log_err()?; - - let actual_dot_git_dir_abs_path = repository.main_repository_path(); - let dot_git_worktree_abs_path = if actual_dot_git_dir_abs_path == dot_git_abs_path { - None - } else { - // The two paths could be different because we opened a git worktree. - // When that happens: - // - // * `dot_git_abs_path` is a file that points to the worktree-subdirectory in the actual - // .git directory. - // - // * `repository_path` is the worktree-subdirectory. - // - // * `actual_dot_git_dir_abs_path` is the path to the actual .git directory. In git - // documentation this is called the "commondir". - watcher.add(&dot_git_abs_path).log_err()?; - Some(Arc::from(dot_git_abs_path.as_path())) + let mut common_dir_abs_path = dot_git_abs_path.clone(); + let mut repository_dir_abs_path = dot_git_abs_path.clone(); + // Parse .git if it's a "gitfile" pointing to a repository directory elsewhere. + if let Some(dot_git_contents) = smol::block_on(fs.load(&dot_git_abs_path)).ok() { + if let Some(path) = dot_git_contents.strip_prefix("gitdir:") { + let path = path.trim(); + let path = dot_git_abs_path + .parent() + .unwrap_or(Path::new("")) + .join(path); + if let Some(path) = smol::block_on(fs.canonicalize(&path)).log_err() { + repository_dir_abs_path = Path::new(&path).into(); + common_dir_abs_path = repository_dir_abs_path.clone(); + if let Some(ancestor_dot_git) = path + .ancestors() + .skip(1) + .find(|ancestor| smol::block_on(is_git_dir(ancestor, fs))) + { + common_dir_abs_path = ancestor_dot_git.into(); + } + } + } else { + log::error!("failed to parse contents of .git file: {dot_git_contents:?}"); + } }; - - log::trace!("constructed libgit2 repo in {:?}", t0.elapsed()); + watcher.add(&common_dir_abs_path).log_err(); let work_directory_id = work_dir_entry.id; let local_repository = LocalRepositoryEntry { work_directory_id, work_directory, - git_dir_scan_id: 0, - original_dot_git_abs_path: dot_git_abs_path.as_path().into(), - dot_git_dir_abs_path: actual_dot_git_dir_abs_path.into(), work_directory_abs_path: work_directory_abs_path.as_path().into(), - dot_git_worktree_abs_path, + git_dir_scan_id: 0, + dot_git_abs_path, + common_dir_abs_path, + repository_dir_abs_path, }; self.snapshot @@ -3454,6 +3478,8 @@ pub struct UpdatedGitRepository { /// For a normal git repository checkout, the absolute path to the .git directory. /// For a worktree, the absolute path to the worktree's subdirectory inside the .git directory. pub dot_git_abs_path: Option>, + pub repository_dir_abs_path: Option>, + pub common_dir_abs_path: Option>, } pub type UpdatedEntriesSet = Arc<[(Arc, ProjectEntryId, PathChange)]>; @@ -4010,8 +4036,8 @@ impl BackgroundScanner { if abs_path.0.file_name() == Some(*GITIGNORE) { for (_, repo) in snapshot.git_repositories.iter().filter(|(_, repo)| repo.directory_contains(&relative_path)) { - if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.dot_git_dir_abs_path.as_ref()) { - dot_git_abs_paths.push(repo.dot_git_dir_abs_path.to_path_buf()); + if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.common_dir_abs_path.as_ref()) { + dot_git_abs_paths.push(repo.common_dir_abs_path.to_path_buf()); } } } @@ -4070,7 +4096,6 @@ impl BackgroundScanner { } } self.send_status_update(false, SmallVec::new()); - // send_status_update_inner(phase, state, status_update_tx, false, SmallVec::new()); } async fn forcibly_load_paths(&self, paths: &[Arc]) -> bool { @@ -4709,8 +4734,8 @@ impl BackgroundScanner { .git_repositories .iter() .find_map(|(_, repo)| { - if repo.dot_git_dir_abs_path.as_ref() == &dot_git_dir - || repo.dot_git_worktree_abs_path.as_deref() == Some(&dot_git_dir) + if repo.common_dir_abs_path.as_ref() == &dot_git_dir + || repo.repository_dir_abs_path.as_ref() == &dot_git_dir { Some(repo.clone()) } else { @@ -4752,7 +4777,7 @@ impl BackgroundScanner { if exists_in_snapshot || matches!( - smol::block_on(self.fs.metadata(&entry.dot_git_dir_abs_path)), + smol::block_on(self.fs.metadata(&entry.common_dir_abs_path)), Ok(Some(_)) ) { @@ -5081,7 +5106,7 @@ impl WorktreeModelHandle for Entity { .unwrap(); ( tree.fs.clone(), - local_repo_entry.dot_git_dir_abs_path.clone(), + local_repo_entry.common_dir_abs_path.clone(), local_repo_entry.git_dir_scan_id, ) }); From 17719f9f87b80fabd2af05c213b781611b9ee0e3 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 11 Apr 2025 21:39:50 -0300 Subject: [PATCH 24/75] agent: Add "Install MCPs" to panel menu (#28616) Also took the opportunity to rename the "Continue in New Thread" item to a potentially clearer name. Release Notes: - N/A --- crates/agent/src/assistant_panel.rs | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 75f4db9ff3..0d8682abf5 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -1088,20 +1088,30 @@ impl AssistantPanel { window, cx, |menu, _window, _cx| { - menu.action( + menu + .when(!is_empty, |menu| { + menu.action( + "Start New From Summary", + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + ).separator() + }) + .action( "New Text Thread", NewTextThread.boxed_clone(), ) - .when(!is_empty, |menu| { - menu.action( - "Continue in New Thread", - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), - }), - ) - }) - .separator() .action("Settings", OpenConfiguration.boxed_clone()) + .separator() + .action( + "Install MCPs", + zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::ContextServers, + ), + } + .boxed_clone(), + ) }, )) }), From fb78cbbd45d07589fc87fd65e05b2b2ca6e4b65e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 11 Apr 2025 21:39:57 -0300 Subject: [PATCH 25/75] agent: Adjust MCP section in the settings view (#28615) Mentioning "MCP" more prominently, adding tool descriptions in the icon button tooltip, and other UI adjustments. Release Notes: - N/A --- crates/agent/src/assistant_configuration.rs | 47 +++++++++++++++------ crates/ui/src/components/disclosure.rs | 16 ++++--- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/agent/src/assistant_configuration.rs b/crates/agent/src/assistant_configuration.rs index 8972d786e2..f464abe1b0 100644 --- a/crates/agent/src/assistant_configuration.rs +++ b/crates/agent/src/assistant_configuration.rs @@ -12,7 +12,9 @@ use fs::Fs; use gpui::{Action, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable, Subscription}; use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry}; use settings::{Settings, update_settings_file}; -use ui::{Disclosure, Divider, DividerColor, ElevationIndex, Indicator, Switch, prelude::*}; +use ui::{ + Disclosure, Divider, DividerColor, ElevationIndex, Indicator, Switch, Tooltip, prelude::*, +}; use util::ResultExt as _; use zed_actions::ExtensionCategoryFilter; @@ -236,7 +238,10 @@ impl AssistantConfiguration { .child( v_flex() .gap_0p5() - .child(Headline::new("Context Servers (MCP)").size(HeadlineSize::Small)) + .child( + Headline::new("Model Context Protocol (MCP) Servers") + .size(HeadlineSize::Small), + ) .child(Label::new(SUBHEADING).color(Color::Muted)), ) .children(context_servers.into_iter().map(|context_server| { @@ -262,10 +267,9 @@ impl AssistantConfiguration { .bg(cx.theme().colors().editor_background) .child( h_flex() + .p_1() .justify_between() - .px_2() - .py_1() - .when(are_tools_expanded, |element| { + .when(are_tools_expanded && tool_count > 1, |element| { element .border_b_1() .border_color(cx.theme().colors().border) @@ -275,6 +279,7 @@ impl AssistantConfiguration { .gap_2() .child( Disclosure::new("tool-list-disclosure", are_tools_expanded) + .disabled(tool_count == 0) .on_click(cx.listener({ let context_server_id = context_server.id(); move |this, _event, _window, _cx| { @@ -295,10 +300,11 @@ impl AssistantConfiguration { .child(Label::new(context_server.id())) .child( Label::new(format!("{tool_count} tools")) - .color(Color::Muted), + .color(Color::Muted) + .size(LabelSize::Small), ), ) - .child(h_flex().child( + .child( Switch::new("context-server-switch", is_running.into()).on_click({ let context_server_manager = self.context_server_manager.clone(); @@ -334,7 +340,7 @@ impl AssistantConfiguration { } } }), - )), + ), ) .map(|parent| { if !are_tools_expanded { @@ -344,14 +350,29 @@ impl AssistantConfiguration { parent.child(v_flex().children(tools.into_iter().enumerate().map( |(ix, tool)| { h_flex() - .px_2() + .id("tool-item") + .pl_2() + .pr_1() .py_1() + .gap_2() + .justify_between() .when(ix < tool_count - 1, |element| { element .border_b_1() - .border_color(cx.theme().colors().border) + .border_color(cx.theme().colors().border_variant) }) - .child(Label::new(tool.name())) + .child( + Label::new(tool.name()) + .buffer_font(cx) + .size(LabelSize::Small), + ) + .child( + IconButton::new(("tool-description", ix), IconName::Info) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text(tool.description())), + ) }, ))) }) @@ -362,7 +383,7 @@ impl AssistantConfiguration { .gap_2() .child( h_flex().w_full().child( - Button::new("add-context-server", "Add Context Server") + Button::new("add-context-server", "Add MCPs Directly") .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) .full_width() @@ -378,7 +399,7 @@ impl AssistantConfiguration { h_flex().w_full().child( Button::new( "install-context-server-extensions", - "Install Context Server Extensions", + "Install MCP Extensions", ) .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index 6460d059a1..a1fab02e54 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -9,6 +9,7 @@ pub struct Disclosure { id: ElementId, is_open: bool, selected: bool, + disabled: bool, on_toggle: Option>, cursor_style: CursorStyle, opened_icon: IconName, @@ -21,6 +22,7 @@ impl Disclosure { id: id.into(), is_open, selected: false, + disabled: false, on_toggle: None, cursor_style: CursorStyle::PointingHand, opened_icon: IconName::ChevronDown, @@ -45,6 +47,11 @@ impl Disclosure { self.closed_icon = icon; self } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } } impl Toggleable for Disclosure { @@ -78,6 +85,7 @@ impl RenderOnce for Disclosure { .shape(IconButtonShape::Square) .icon_color(Color::Muted) .icon_size(IconSize::Small) + .disabled(self.disabled) .toggle_state(self.selected) .when_some(self.on_toggle, move |this, on_toggle| { this.on_click(move |event, window, cx| on_toggle(event, window, cx)) @@ -120,13 +128,7 @@ impl Component for Disclosure { "Toggleable", v_flex() .gap_2() - .child( - Disclosure::new("interactive", false) - // .on_toggle(Some(Arc::new(|_, _, cx| { - // cx.refresh(); - // }))) - .into_any_element(), - ) + .child(Disclosure::new("interactive", false).into_any_element()) .child(Label::new("Click to toggle")) .into_any_element(), )], From 8ffa58414d7a42e9e9d8ca3be84b3e8fea6483b0 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:05:11 -0300 Subject: [PATCH 26/75] agent: Increase message editor height (#28618) It was too tiny, felt like we could use more breathing room. Release Notes: - N/A --- crates/agent/src/message_editor.rs | 53 ++++++++++++++++-------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index acc23dce44..bdc7c67cff 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -56,7 +56,7 @@ pub struct MessageEditor { _subscriptions: Vec, } -const MAX_EDITOR_LINES: usize = 10; +const MAX_EDITOR_LINES: usize = 3; impl MessageEditor { pub fn new( @@ -424,31 +424,36 @@ impl MessageEditor { .when(is_editor_expanded, |this| { this.h(vh(0.8, window)).justify_between() }) - .child(div().when(is_editor_expanded, |this| this.h_full()).child({ - let settings = ThemeSettings::get_global(cx); + .child( + div() + .min_h_16() + .when(is_editor_expanded, |this| this.h_full()) + .child({ + let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - line_height: line_height.into(), - ..Default::default() - }; + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + line_height: line_height.into(), + ..Default::default() + }; - EditorElement::new( - &self.editor, - EditorStyle { - background: editor_bg_color, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - ..Default::default() - }, - ) - .into_any() - })) + EditorElement::new( + &self.editor, + EditorStyle { + background: editor_bg_color, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + .into_any() + }), + ) .child( h_flex() .flex_none() From d1ffda9bfeccfdf9bea3f76251350bf9cf7f6e1b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:36:26 -0300 Subject: [PATCH 27/75] agent: Display keybindings for "Reject All" and "Keep All" (#28620) Release Notes: - N/A --- assets/keymaps/default-linux.json | 4 +++- assets/keymaps/default-macos.json | 4 +++- crates/agent/src/agent_diff.rs | 32 +++++++++++++++++++++---------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 5b6e410ae9..9c4a4d1f50 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -150,7 +150,9 @@ "context": "AgentDiff", "bindings": { "ctrl-y": "agent::Keep", - "ctrl-n": "agent::Reject" + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4b59db20a5..ee24d9deb7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -242,7 +242,9 @@ "use_key_equivalents": true, "bindings": { "cmd-y": "agent::Keep", - "cmd-n": "agent::Reject" + "cmd-n": "agent::Reject", + "cmd-shift-y": "agent::KeepAll", + "cmd-shift-n": "agent::RejectAll" } }, { diff --git a/crates/agent/src/agent_diff.rs b/crates/agent/src/agent_diff.rs index c3bc120ead..13f2f991fb 100644 --- a/crates/agent/src/agent_diff.rs +++ b/crates/agent/src/agent_diff.rs @@ -1,4 +1,4 @@ -use crate::{Keep, Reject, Thread, ThreadEvent}; +use crate::{Keep, KeepAll, Reject, RejectAll, Thread, ThreadEvent}; use anyhow::Result; use buffer_diff::DiffHunkStatus; use collections::HashSet; @@ -843,7 +843,7 @@ impl ToolbarItemView for AgentDiffToolbar { } impl Render for AgentDiffToolbar { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_diff = match self.agent_diff(cx) { Some(ad) => ad, None => return div(), @@ -855,6 +855,8 @@ impl Render for AgentDiffToolbar { return div(); } + let focus_handle = agent_diff.focus_handle(cx); + h_group_xl() .my_neg_1() .items_center() @@ -864,15 +866,25 @@ impl Render for AgentDiffToolbar { .child( h_group_sm() .child( - Button::new("reject-all", "Reject All").on_click(cx.listener( - |this, _, window, cx| { - this.dispatch_action(&crate::RejectAll, window, cx) - }, - )), + Button::new("reject-all", "Reject All") + .key_binding({ + KeyBinding::for_action_in(&RejectAll, &focus_handle, window, cx) + .map(|kb| kb.size(rems_from_px(12.))) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&RejectAll, window, cx) + })), ) - .child(Button::new("keep-all", "Keep All").on_click(cx.listener( - |this, _, window, cx| this.dispatch_action(&crate::KeepAll, window, cx), - ))), + .child( + Button::new("keep-all", "Keep All") + .key_binding({ + KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx) + .map(|kb| kb.size(rems_from_px(12.))) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&KeepAll, window, cx) + })), + ), ) } } From e4844b281d52a181802bb800f19d74853469766a Mon Sep 17 00:00:00 2001 From: hrou0003 <54772688+hrou0003@users.noreply.github.com> Date: Sat, 12 Apr 2025 22:54:47 +1000 Subject: [PATCH 28/75] Keep .vscode folder included during initialization even if it's in .gitignore (#28631) This fixes an issue where tasks in `.vscode/tasks.json` weren't being loaded at startup of a project Closes #28494 Release Notes: - Tasks are now loaded from local `.vscode/tasks.json` files even if they are `.gitignore`d --- crates/paths/src/paths.rs | 5 +++++ crates/worktree/src/worktree.rs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 622e4a67f3..9f7644ee9b 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -375,6 +375,11 @@ pub fn local_settings_folder_relative_path() -> &'static Path { Path::new(".zed") } +/// Returns the relative path to a `.vscode` folder within a project. +pub fn local_vscode_folder_relative_path() -> &'static Path { + Path::new(".vscode") +} + /// Returns the relative path to a `settings.json` file within a project. pub fn local_settings_file_relative_path() -> &'static Path { Path::new(".zed/settings.json") diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index eff02a5cfa..fa4bf202d8 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -29,7 +29,7 @@ use ignore::IgnoreStack; use language::DiskState; use parking_lot::Mutex; -use paths::local_settings_folder_relative_path; +use paths::{local_settings_folder_relative_path, local_vscode_folder_relative_path}; use postage::{ barrier, prelude::{Sink as _, Stream as _}, @@ -2865,6 +2865,7 @@ impl BackgroundScannerState { (!entry.is_external && (!entry.is_ignored || entry.is_always_included)) || entry.path.file_name() == Some(*DOT_GIT) || entry.path.file_name() == Some(local_settings_folder_relative_path().as_os_str()) + || entry.path.file_name() == Some(local_vscode_folder_relative_path().as_os_str()) || self.scanned_dirs.contains(&entry.id) // If we've ever scanned it, keep scanning || self .paths_to_scan From b864a9b0ae633006a44c02b723fe6fad07e84b93 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Sun, 13 Apr 2025 00:32:55 +0530 Subject: [PATCH 29/75] hover_popover: Fix markdown selection for info and diagnostic popovers (#28642) Closes #28638 This PR fixes markdown selection for the info and diagnostic popovers. In the editor popover, after the changes in https://github.com/zed-industries/zed/pull/28255, the markdown selection state updates correctly, but it no longer triggers the editor element to repaint like it used to. This is fixed by adding a subscription to listen for markdown entity changes and triggering a repaint for the editor. I assume markdown selection works elsewhere because: 1. Either the `Markdown` entity is directly part of a struct that implements the `Render` trait, causing it to repaint whenever the markdown state changes. See [here](https://github.com/zed-industries/zed/blob/d1ffda9bfeccfdf9bea3f76251350bf9cf7f6e1b/crates/ui_prompt/src/ui_prompt.rs#L65). 2. OR it's wrapped around component like Popover which implements `RenderOnce` trait. See [here](https://github.com/zed-industries/zed/blob/d1ffda9bfeccfdf9bea3f76251350bf9cf7f6e1b/crates/editor/src/code_context_menus.rs#L645). Whereas info and diagnostic popovers does not do both. I do think we can change it to use `Popover` component, but for now this works as quick fix. Extras: - Remove unnecessary struct cloning. - Refactor rendering logic to use `when_some`. Release Notes: - Fixed issue where selection wasn't working for info and diagnostic popovers. --- crates/editor/src/hover_popover.rs | 294 ++++++++++++++++------------- 1 file changed, 165 insertions(+), 129 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f876fab52c..fd53c4b0ad 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -8,8 +8,8 @@ use crate::{ use gpui::{ AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, - Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Task, TextStyleRefinement, - Window, div, px, + Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, + TextStyleRefinement, Window, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; @@ -64,26 +64,31 @@ pub fn show_keyboard_hover( window: &mut Window, cx: &mut Context, ) -> bool { - let info_popovers = editor.hover_state.info_popovers.clone(); - for p in info_popovers { - let keyboard_grace = p.keyboard_grace.borrow(); - if *keyboard_grace { - if let Some(anchor) = p.anchor { - show_hover(editor, anchor, false, window, cx); - return true; - } + if let Some(anchor) = editor.hover_state.info_popovers.iter().find_map(|p| { + if *p.keyboard_grace.borrow() { + p.anchor + } else { + None } + }) { + show_hover(editor, anchor, false, window, cx); + return true; } - let diagnostic_popover = editor.hover_state.diagnostic_popover.clone(); - if let Some(d) = diagnostic_popover { - let keyboard_grace = d.keyboard_grace.borrow(); - if *keyboard_grace { - if let Some(anchor) = d.anchor { - show_hover(editor, anchor, false, window, cx); - return true; + if let Some(anchor) = editor + .hover_state + .diagnostic_popover + .as_ref() + .and_then(|d| { + if *d.keyboard_grace.borrow() { + d.anchor + } else { + None } - } + }) + { + show_hover(editor, anchor, false, window, cx); + return true; } false @@ -164,6 +169,18 @@ pub fn hover_at_inlay( let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await; let scroll_handle = ScrollHandle::new(); + + let subscription = this + .update(cx, |_, cx| { + if let Some(parsed_content) = &parsed_content { + Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) + } else { + None + } + }) + .ok() + .flatten(); + let hover_popover = InfoPopover { symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()), parsed_content, @@ -171,6 +188,7 @@ pub fn hover_at_inlay( scroll_handle, keyboard_grace: Rc::new(RefCell::new(false)), anchor: None, + _subscription: subscription, }; this.update(cx, |this, cx| { @@ -307,40 +325,44 @@ fn show_hover( .anchor_after(local_diagnostic.range.end), }; - let mut border_color: Option = None; - let mut background_color: Option = None; + let (background_color, border_color) = cx.update(|_, cx| { + let status_colors = cx.theme().status(); + match local_diagnostic.diagnostic.severity { + DiagnosticSeverity::ERROR => { + (status_colors.error_background, status_colors.error_border) + } + DiagnosticSeverity::WARNING => ( + status_colors.warning_background, + status_colors.warning_border, + ), + DiagnosticSeverity::INFORMATION => { + (status_colors.info_background, status_colors.info_border) + } + DiagnosticSeverity::HINT => { + (status_colors.hint_background, status_colors.hint_border) + } + _ => ( + status_colors.ignored_background, + status_colors.ignored_border, + ), + } + })?; let parsed_content = cx - .new_window_entity(|_window, cx| { - let status_colors = cx.theme().status(); - - match local_diagnostic.diagnostic.severity { - DiagnosticSeverity::ERROR => { - background_color = Some(status_colors.error_background); - border_color = Some(status_colors.error_border); - } - DiagnosticSeverity::WARNING => { - background_color = Some(status_colors.warning_background); - border_color = Some(status_colors.warning_border); - } - DiagnosticSeverity::INFORMATION => { - background_color = Some(status_colors.info_background); - border_color = Some(status_colors.info_border); - } - DiagnosticSeverity::HINT => { - background_color = Some(status_colors.hint_background); - border_color = Some(status_colors.hint_border); - } - _ => { - background_color = Some(status_colors.ignored_background); - border_color = Some(status_colors.ignored_border); - } - }; - - Markdown::new_text(SharedString::new(text), cx) - }) + .new(|cx| Markdown::new_text(SharedString::new(text), cx)) .ok(); + let subscription = this + .update(cx, |_, cx| { + if let Some(parsed_content) = &parsed_content { + Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) + } else { + None + } + }) + .ok() + .flatten(); + Some(DiagnosticPopover { local_diagnostic, parsed_content, @@ -348,6 +370,7 @@ fn show_hover( background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + _subscription: subscription, }) } else { None @@ -400,6 +423,16 @@ fn show_hover( }]; let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await; let scroll_handle = ScrollHandle::new(); + let subscription = this + .update(cx, |_, cx| { + if let Some(parsed_content) = &parsed_content { + Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) + } else { + None + } + }) + .ok() + .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(range), parsed_content, @@ -407,6 +440,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + _subscription: subscription, }) } @@ -440,6 +474,16 @@ fn show_hover( let parsed_content = parse_blocks(&blocks, &language_registry, language, cx).await; let scroll_handle = ScrollHandle::new(); hover_highlights.push(range.clone()); + let subscription = this + .update(cx, |_, cx| { + if let Some(parsed_content) = &parsed_content { + Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) + } else { + None + } + }) + .ok() + .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(range), parsed_content, @@ -447,6 +491,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + _subscription: subscription, }); } @@ -660,7 +705,7 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) cx.open_url(&link); } -#[derive(Default, Debug)] +#[derive(Default)] pub struct HoverState { pub info_popovers: Vec, pub diagnostic_popover: Option, @@ -742,7 +787,6 @@ impl HoverState { } } -#[derive(Debug, Clone)] pub(crate) struct InfoPopover { pub(crate) symbol_range: RangeInEditor, pub(crate) parsed_content: Option>, @@ -750,6 +794,7 @@ pub(crate) struct InfoPopover { pub(crate) scrollbar_state: ScrollbarState, pub(crate) keyboard_grace: Rc>, pub(crate) anchor: Option, + _subscription: Option, } impl InfoPopover { @@ -760,7 +805,7 @@ impl InfoPopover { cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); - let mut d = div() + div() .id("info_popover") .elevation_2(cx) // Prevent a mouse down/move on the popover from being propagated to the editor, @@ -770,11 +815,9 @@ impl InfoPopover { let mut keyboard_grace = keyboard_grace.borrow_mut(); *keyboard_grace = false; cx.stop_propagation(); - }); - - if let Some(markdown) = &self.parsed_content { - d = d - .child( + }) + .when_some(self.parsed_content.clone(), |this, markdown| { + this.child( div() .id("info-md-container") .overflow_y_scroll() @@ -783,19 +826,16 @@ impl InfoPopover { .p_2() .track_scroll(&self.scroll_handle) .child( - MarkdownElement::new( - markdown.clone(), - hover_markdown_style(window, cx), - ) - .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - }) - .on_url_click(open_markdown_url), + MarkdownElement::new(markdown, hover_markdown_style(window, cx)) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + }) + .on_url_click(open_markdown_url), ), ) - .child(self.render_vertical_scrollbar(cx)); - } - d.into_any_element() + .child(self.render_vertical_scrollbar(cx)) + }) + .into_any_element() } pub fn scroll(&self, amount: &ScrollAmount, window: &mut Window, cx: &mut Context) { @@ -842,14 +882,14 @@ impl InfoPopover { } } -#[derive(Debug, Clone)] pub struct DiagnosticPopover { pub(crate) local_diagnostic: DiagnosticEntry, parsed_content: Option>, - border_color: Option, - background_color: Option, + border_color: Hsla, + background_color: Hsla, pub keyboard_grace: Rc>, pub anchor: Option, + _subscription: Option, } impl DiagnosticPopover { @@ -860,53 +900,7 @@ impl DiagnosticPopover { cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); - let mut markdown_div = div().py_1().px_2(); - if let Some(markdown) = &self.parsed_content { - let settings = ThemeSettings::get_global(cx); - let mut base_text_style = window.text_style(); - base_text_style.refine(&TextStyleRefinement { - font_family: Some(settings.ui_font.family.clone()), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: Some(settings.ui_font_size(cx).into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(gpui::transparent_black()), - ..Default::default() - }); - let markdown_style = MarkdownStyle { - base_text_style, - selection_background_color: { cx.theme().players().local().selection }, - link: TextStyleRefinement { - underline: Some(gpui::UnderlineStyle { - thickness: px(1.), - color: Some(cx.theme().colors().editor_foreground), - wavy: false, - }), - ..Default::default() - }, - ..Default::default() - }; - - markdown_div = markdown_div.child( - MarkdownElement::new(markdown.clone(), markdown_style) - .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - }) - .on_url_click(open_markdown_url), - ); - } - - if let Some(background_color) = &self.background_color { - markdown_div = markdown_div.bg(*background_color); - } - - if let Some(border_color) = &self.border_color { - markdown_div = markdown_div - .border_1() - .border_color(*border_color) - .rounded_lg(); - } - - let diagnostic_div = div() + div() .id("diagnostic") .block() .max_h(max_size.height) @@ -928,9 +922,51 @@ impl DiagnosticPopover { *keyboard_grace = false; cx.stop_propagation(); }) - .child(markdown_div); - - diagnostic_div.into_any_element() + .when_some(self.parsed_content.clone(), |this, markdown| { + this.child( + div() + .py_1() + .px_2() + .child( + MarkdownElement::new(markdown, { + let settings = ThemeSettings::get_global(cx); + let mut base_text_style = window.text_style(); + base_text_style.refine(&TextStyleRefinement { + font_family: Some(settings.ui_font.family.clone()), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: Some(settings.ui_font_size(cx).into()), + color: Some(cx.theme().colors().editor_foreground), + background_color: Some(gpui::transparent_black()), + ..Default::default() + }); + MarkdownStyle { + base_text_style, + selection_background_color: { + cx.theme().players().local().selection + }, + link: TextStyleRefinement { + underline: Some(gpui::UnderlineStyle { + thickness: px(1.), + color: Some(cx.theme().colors().editor_foreground), + wavy: false, + }), + ..Default::default() + }, + ..Default::default() + } + }) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + }) + .on_url_click(open_markdown_url), + ) + .bg(self.background_color) + .border_1() + .border_color(self.border_color) + .rounded_lg(), + ) + }) + .into_any_element() } } @@ -1070,7 +1106,7 @@ mod tests { editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state @@ -1110,7 +1146,7 @@ mod tests { editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state @@ -1205,7 +1241,7 @@ mod tests { editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state @@ -1270,7 +1306,7 @@ mod tests { editor.hover_state.info_popovers.len(), 0, "Expected no hovers but got but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); }); @@ -1294,7 +1330,7 @@ mod tests { editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); let rendered_text = editor @@ -1352,7 +1388,7 @@ mod tests { editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state @@ -1418,7 +1454,7 @@ mod tests { editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", - editor.hover_state.info_popovers + editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state @@ -1795,7 +1831,7 @@ mod tests { assert!( hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1 ); - let popover = hover_state.info_popovers.first().cloned().unwrap(); + let popover = hover_state.info_popovers.first().unwrap(); let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); assert_eq!( popover.symbol_range, @@ -1850,7 +1886,7 @@ mod tests { assert!( hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1 ); - let popover = hover_state.info_popovers.first().cloned().unwrap(); + let popover = hover_state.info_popovers.first().unwrap(); let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); assert_eq!( popover.symbol_range, From 77544f42b1c92451676e71fbe849ec9be1f84c09 Mon Sep 17 00:00:00 2001 From: loczek <30776250+loczek@users.noreply.github.com> Date: Sun, 13 Apr 2025 19:53:22 +0200 Subject: [PATCH 30/75] snippets: Fix plaintext snippets not working (#28655) This PR fixes a minor regression introduced in #27718, where snippets stopped working when the language was set to plaintext because `languages_at` doesn't include plaintext, while `language_at` does. Release Notes: - Fixed plaintext snippets not working --- crates/language/src/buffer.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index aeb870288c..0d6f797bc3 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1376,11 +1376,20 @@ impl Buffer { /// Returns each [`Language`] for the active syntax layers at the given location. pub fn languages_at(&self, position: D) -> Vec> { let offset = position.to_offset(self); - self.syntax_map + let mut languages: Vec> = self + .syntax_map .lock() .layers_for_range(offset..offset, &self.text, false) .map(|info| info.language.clone()) - .collect() + .collect(); + + if languages.is_empty() { + if let Some(buffer_language) = self.language() { + languages.push(buffer_language.clone()); + } + } + + languages } /// An integer version number that accounts for all updates besides From b25c3334cc3452721b263beccd894b95da5dc8ad Mon Sep 17 00:00:00 2001 From: hrou0003 <54772688+hrou0003@users.noreply.github.com> Date: Mon, 14 Apr 2025 03:57:05 +1000 Subject: [PATCH 31/75] Detect decorated pytest methods as runnable (#28652) Closes #28096 Release Notes: - Fixed decorated pytest methods not being picked up as runnable --- crates/languages/src/python/runnables.scm | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/languages/src/python/runnables.scm b/crates/languages/src/python/runnables.scm index 3b32556707..84a024de94 100644 --- a/crates/languages/src/python/runnables.scm +++ b/crates/languages/src/python/runnables.scm @@ -83,6 +83,26 @@ ) ) +; decorated pytest class methods +( + (module + (class_definition + name: (identifier) @_pytest_class_name + (#match? @_pytest_class_name "^Test") + body: (block + (decorated_definition + (decorator)+ @_decorator + definition: (function_definition + name: (identifier) @run @_pytest_method_name + (#match? @_pytest_method_name "^test_") + ) + ) + ) @_python-pytest-method + (#set! tag python-pytest-method) + ) + ) +) + ; module main method ( (module From 128779f6155ed9a48929b6eac23b10327a15fffb Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Sun, 13 Apr 2025 18:15:57 +0000 Subject: [PATCH 32/75] docs: Improve Lua language documentation (#28662) Release Notes: - N/A --- docs/src/languages/lua.md | 96 +++++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/docs/src/languages/lua.md b/docs/src/languages/lua.md index b10aa068b4..4ad143ce41 100644 --- a/docs/src/languages/lua.md +++ b/docs/src/languages/lua.md @@ -9,29 +9,107 @@ Lua support is available through the [Lua extension](https://github.com/zed-exte To configure LuaLS you can create a `.luarc.json` file in the root of your workspace. -See [LuaLS Settings Documentation](https://luals.github.io/wiki/settings/) for all available configuration options. +```json +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "Lua 5.4", + "format.enable": true, + "workspace.library": ["../somedir/library"] +} +``` + +See [LuaLS Settings Documentation](https://luals.github.io/wiki/settings/) for all available configuration options, or when editing this file in Zed available settings options will autocomplete, (e.g `runtime.version` will show `"Lua 5.1"`, `"Lua 5.2"`, `"Lua 5.3"`, `"Lua 5.4"` and `"LuaJIT"` as allowed values). Note when importing settings options from VSCode, remove the `Lua.` prefix. (e.g. `runtime.version` instead of `Lua.runtime.version`). + +### LuaCATS Definitions + +LuaLS can provide enhanced LSP autocompletion suggestions and type validation with the help of LuaCATS (Lua Comment and Type System) definitions. These definitions are available for many common Lua libraries, and local paths containing them can be specified via `workspace.library` in `luarc.json`. You can do this via relative paths if you checkout your definitions into the same partent directory of your project (`../playdate-luacats`, `../love2d`, etc). Alternatively you can create submodule(s) inside your project for each LuaCATS definition repo. + +### LÖVE (Love2D) {#love2d} + +To use [LÖVE (Love2D)](https://love2d.org/) in Zed, checkout [LuaCATS/love2d](https://github.com/LuaCATS/love2d) into a folder called `love2d-luacats` into the parent folder of your project: + +```sh +cd .. && git clone https://github.com/LuaCATS/love2d love2d-luacats +``` + +Then in your `.luarc.json`: + +``` +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "Lua 5.4", + "workspace.library": ["../love2d-luacats"], + "runtime.special": { + "love.filesystem.load": "loadfile" + } +} +``` + +### PlaydateSDK + +To use [Playdate Lua SDK](https://play.date/dev/) in Zed, checkout [playdate-luacats](https://github.com/notpeter/playdate-luacats) into the parent folder of your project: + +```sh +cd .. && git clone https://github.com/notpeter/playdate-luacats +``` + +Then in your `.luarc.json`: ```json { "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", "runtime.version": "Lua 5.4", - "diagnostics.severity": { - "duplicate-set-field": "Hint" - }, - "format.enable": true, + "runtime.nonstandardSymbol": [ + "+=", + "-=", + "*=", + "/=", + "//=", + "%=", + "<<=", + ">>=", + "&=", + "|=", + "^=" + ], + "diagnostics.severity": { "duplicate-set-field": "Hint" }, + "diagnostics.globals": ["import"], + "workspace.library": ["../playdate-luacats"], "format.defaultConfig": { "indent_style": "space", "indent_size": "4" }, - "workspace.library": ["../somedir/library"] + "format.enable": true, + "runtime.builtin": { "io": "disable", "os": "disable", "package": "disable" } } ``` +### Inlay Hints + +To enable [Inlay Hints](../configuring-languages#inlay-hints) for LuaLS in Zed + +1. Add the following to your Zed settings.json: + +```json + "languages": { + "Lua": { + "inlay_hints": { + "enabled": true, + "show_type_hints": true, + "show_parameter_hints": true, + "show_other_hints": true + } + } + } +``` + +2. Add `"hint.enable": true` to your `.luarc.json`. + ## Formatting ### LuaLS -To enable auto-formatting with your LuaLS, make sure you have `"format.enable": true,` in your .luarc.json add the following to your Zed `settings.json`: +To enable auto-formatting with your LuaLS (provided by [CppCXY/EmmyLuaCodeStyle](https://github.com/CppCXY/EmmyLuaCodeStyle)) make sure you have `"format.enable": true,` in your .luarc.json add the following to your Zed `settings.json`: ```json { @@ -44,9 +122,11 @@ To enable auto-formatting with your LuaLS, make sure you have `"format.enable": } ``` +You can customize various EmmyLuaCodeStyle style options via `.editorconfig`, see [lua.template.editorconfig](https://github.com/CppCXY/EmmyLuaCodeStyle/blob/master/lua.template.editorconfig) for all available options. + ### StyLua -Alternative you can use [StyLua](https://github.com/JohnnyMorganz/StyLua): +Alternatively to use [StyLua](https://github.com/JohnnyMorganz/StyLua) for auto-formatting: 1. Install [StyLua](https://github.com/JohnnyMorganz/StyLua): `brew install stylua` or `cargo install stylua --features lua52,lua53,lua54,luau,luajit` (feel free to remove any Lua versions you don't need). 2. Add the following to your `settings.json`: From 5e57f148acd05da412df80b55fe26171d268d9b0 Mon Sep 17 00:00:00 2001 From: maan2003 <49202620+maan2003@users.noreply.github.com> Date: Mon, 14 Apr 2025 13:44:54 +0530 Subject: [PATCH 33/75] nix: Bump rust-overlay for Rust 1.86 (#28181) otherwise nix develop doesn't work, complains about not knowing about rust 1.86 Release Notes: - N/A --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 45fbb2d49f..c09eb90d2d 100644 --- a/flake.lock +++ b/flake.lock @@ -61,11 +61,11 @@ ] }, "locked": { - "lastModified": 1743215516, - "narHash": "sha256-52qbrkG65U1hyrQWltgHTgH4nm0SJL+9TWv2UDCEPNI=", + "lastModified": 1743906877, + "narHash": "sha256-Thah1oU8Vy0gs9bh5QhNcQh1iuQiowMnZPbrkURonZA=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "524463199fdee49338006b049bc376b965a2cfed", + "rev": "9d00c6b69408dd40d067603012938d9fbe95cfcd", "type": "github" }, "original": { From 98891e4c702badb4c9323a8f9bd4354b53938408 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 14 Apr 2025 13:18:45 +0200 Subject: [PATCH 34/75] language: Optimize language_for_file (#28671) While working on #28670 this function showed up in my profiles; this PR makes it evaluate some of it's conditions lazily + prevent constant rebuilding of globset::Candidates. Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/language/src/language_registry.rs | 62 ++++++++++++++---------- crates/language/src/language_settings.rs | 6 +-- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index a575e08022..d7a4293ee4 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -8,7 +8,7 @@ use crate::{ with_parser, }; use anyhow::{Context as _, Result, anyhow}; -use collections::{HashMap, HashSet, hash_map}; +use collections::{FxHashMap, HashMap, HashSet, hash_map}; use futures::{ Future, @@ -21,8 +21,10 @@ use parking_lot::{Mutex, RwLock}; use postage::watch; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use smallvec::SmallVec; use std::{ borrow::{Borrow, Cow}, + cell::LazyCell, ffi::OsStr, ops::Not, path::{Path, PathBuf}, @@ -674,7 +676,7 @@ impl LanguageRegistry { self: &Arc, path: &Path, content: Option<&Rope>, - user_file_types: Option<&HashMap, GlobSet>>, + user_file_types: Option<&FxHashMap, GlobSet>>, ) -> Option { let filename = path.file_name().and_then(|name| name.to_str()); // `Path.extension()` returns None for files with a leading '.' @@ -682,32 +684,42 @@ impl LanguageRegistry { // as we want `.zshrc` to result in extension being `Some("zshrc")` let extension = filename.and_then(|filename| filename.split('.').next_back()); let path_suffixes = [extension, filename, path.to_str()]; + let path_suffixes_candidates = path_suffixes + .iter() + .filter_map(|suffix| suffix.map(globset::Candidate::new)) + .collect::>(); let empty = GlobSet::empty(); - + let content = LazyCell::new(|| { + content.map(|content| { + let end = content.clip_point(Point::new(0, 256), Bias::Left); + let end = content.point_to_offset(end); + content.chunks_in_range(0..end).collect::() + }) + }); self.find_matching_language(move |language_name, config| { - let path_matches_default_suffix = config - .path_suffixes - .iter() - .any(|suffix| path_suffixes.contains(&Some(suffix.as_str()))); - let custom_suffixes = user_file_types - .and_then(|types| types.get(language_name.as_ref())) - .unwrap_or(&empty); - let path_matches_custom_suffix = path_suffixes - .iter() - .map(|suffix| suffix.unwrap_or("")) - .any(|suffix| custom_suffixes.is_match(suffix)); - let content_matches = content.zip(config.first_line_pattern.as_ref()).map_or( - false, - |(content, pattern)| { - let end = content.clip_point(Point::new(0, 256), Bias::Left); - let end = content.point_to_offset(end); - let text = content.chunks_in_range(0..end).collect::(); - pattern.is_match(&text) - }, - ); - if path_matches_custom_suffix { + let path_matches_default_suffix = || { + config + .path_suffixes + .iter() + .any(|suffix| path_suffixes.contains(&Some(suffix.as_str()))) + }; + let path_matches_custom_suffix = || { + let custom_suffixes = user_file_types + .and_then(|types| types.get(language_name.as_ref())) + .unwrap_or(&empty); + path_suffixes_candidates + .iter() + .any(|suffix| custom_suffixes.is_match_candidate(suffix)) + }; + let content_matches = || { + content + .as_ref() + .zip(config.first_line_pattern.as_ref()) + .map_or(false, |(text, pattern)| pattern.is_match(&text)) + }; + if path_matches_custom_suffix() { 2 - } else if path_matches_default_suffix || content_matches { + } else if path_matches_default_suffix() || content_matches() { 1 } else { 0 diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index ca2c33419f..56ffbbef2f 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -2,7 +2,7 @@ use crate::{File, Language, LanguageName, LanguageServerName}; use anyhow::Result; -use collections::{HashMap, HashSet}; +use collections::{FxHashMap, HashMap, HashSet}; use core::slice; use ec4rs::{ Properties as EditorconfigProperties, @@ -63,7 +63,7 @@ pub struct AllLanguageSettings { pub edit_predictions: EditPredictionSettings, pub defaults: LanguageSettings, languages: HashMap, - pub(crate) file_types: HashMap, GlobSet>, + pub(crate) file_types: FxHashMap, GlobSet>, } /// The settings for a particular language. @@ -1217,7 +1217,7 @@ impl settings::Settings for AllLanguageSettings { .map(|settings| settings.enabled_in_assistant) .unwrap_or(true); - let mut file_types: HashMap, GlobSet> = HashMap::default(); + let mut file_types: FxHashMap, GlobSet> = FxHashMap::default(); for (language, suffixes) in &default_value.file_types { let mut builder = GlobSetBuilder::new(); From f2ce1832860e3e69d4bd99c09be8ebc3f8087e59 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 14 Apr 2025 17:44:00 +0530 Subject: [PATCH 35/75] editor: Show code actions in mouse context menu (#28677) Closes #27989 Asynchronous fetch of code actions on right-click, and shows them in context menu. https://github.com/user-attachments/assets/413eb0dd-cd1c-4628-a6f1-84eac813da32 Release Notes: - Improved visibility of code actions by showing them in right-click context menu. --- crates/collab/src/tests/editor_tests.rs | 10 +- crates/editor/src/actions.rs | 3 + crates/editor/src/code_context_menus.rs | 12 +- crates/editor/src/editor.rs | 240 +++++++++++-------- crates/editor/src/mouse_context_menu.rs | 306 ++++++++++++++++++------ 5 files changed, 398 insertions(+), 173 deletions(-) diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 719b8643f2..8a039da882 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -694,7 +694,15 @@ async fn test_collaborating_with_code_actions( // Confirming the code action will trigger a resolve request. let confirm_action = editor_b .update_in(cx_b, |editor, window, cx| { - Editor::confirm_code_action(editor, &ConfirmCodeAction { item_ix: Some(0) }, window, cx) + Editor::confirm_code_action( + editor, + &ConfirmCodeAction { + item_ix: Some(0), + from_mouse_context_menu: false, + }, + window, + cx, + ) }) .unwrap(); fake_language_server.set_request_handler::( diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ecc0823eb1..5ee1492b5b 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -99,6 +99,9 @@ pub struct ComposeCompletion { pub struct ConfirmCodeAction { #[serde(default)] pub item_ix: Option, + #[serde(default)] + #[serde(skip)] + pub from_mouse_context_menu: bool, } #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index caf555bc30..de8416a57f 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -774,7 +774,7 @@ pub struct AvailableCodeAction { pub provider: Rc, } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct CodeActionContents { pub tasks: Option>, pub actions: Option>, @@ -790,7 +790,7 @@ impl CodeActionContents { } } - fn is_empty(&self) -> bool { + pub fn is_empty(&self) -> bool { match (&self.tasks, &self.actions) { (Some(tasks), Some(actions)) => actions.is_empty() && tasks.templates.is_empty(), (Some(tasks), None) => tasks.templates.is_empty(), @@ -799,7 +799,7 @@ impl CodeActionContents { } } - fn iter(&self) -> impl Iterator + '_ { + pub fn iter(&self) -> impl Iterator + '_ { self.tasks .iter() .flat_map(|tasks| { @@ -867,14 +867,14 @@ pub enum CodeActionsItem { } impl CodeActionsItem { - fn as_task(&self) -> Option<&ResolvedTask> { + pub fn as_task(&self) -> Option<&ResolvedTask> { let Self::Task(_, task) = self else { return None; }; Some(task) } - fn as_code_action(&self) -> Option<&CodeAction> { + pub fn as_code_action(&self) -> Option<&CodeAction> { let Self::CodeAction { action, .. } = self else { return None; }; @@ -1014,6 +1014,7 @@ impl CodeActionsMenu { if let Some(task) = editor.confirm_code_action( &ConfirmCodeAction { item_ix: Some(item_ix), + from_mouse_context_menu: false, }, window, cx, @@ -1039,6 +1040,7 @@ impl CodeActionsMenu { if let Some(task) = editor.confirm_code_action( &ConfirmCodeAction { item_ix: Some(item_ix), + from_mouse_context_menu: false, }, window, cx, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b210050b77..d00d8e9b39 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1693,6 +1693,7 @@ impl Editor { self.mouse_context_menu = Some(MouseContextMenu::new( crate::mouse_context_menu::MenuPosition::PinnedToScreen(position), context_menu, + None, window, cx, )); @@ -4833,6 +4834,89 @@ impl Editor { })) } + fn prepare_code_actions_task( + &mut self, + action: &ToggleCodeActions, + window: &mut Window, + cx: &mut Context, + ) -> Task, CodeActionContents)>> { + let snapshot = self.snapshot(window, cx); + let multibuffer_point = action + .deployed_from_indicator + .map(|row| DisplayPoint::new(row, 0).to_point(&snapshot)) + .unwrap_or_else(|| self.selections.newest::(cx).head()); + + let Some((buffer, buffer_row)) = snapshot + .buffer_snapshot + .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) + .and_then(|(buffer_snapshot, range)| { + self.buffer + .read(cx) + .buffer(buffer_snapshot.remote_id()) + .map(|buffer| (buffer, range.start.row)) + }) + else { + return Task::ready(None); + }; + + let (_, code_actions) = self + .available_code_actions + .clone() + .and_then(|(location, code_actions)| { + let snapshot = location.buffer.read(cx).snapshot(); + let point_range = location.range.to_point(&snapshot); + let point_range = point_range.start.row..=point_range.end.row; + if point_range.contains(&buffer_row) { + Some((location, code_actions)) + } else { + None + } + }) + .unzip(); + + let buffer_id = buffer.read(cx).remote_id(); + let tasks = self + .tasks + .get(&(buffer_id, buffer_row)) + .map(|t| Arc::new(t.to_owned())); + + if tasks.is_none() && code_actions.is_none() { + return Task::ready(None); + } + + self.completion_tasks.clear(); + self.discard_inline_completion(false, cx); + + let task_context = tasks + .as_ref() + .zip(self.project.clone()) + .map(|(tasks, project)| { + Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx) + }); + + cx.spawn_in(window, async move |_, _| { + let task_context = match task_context { + Some(task_context) => task_context.await, + None => None, + }; + let resolved_tasks = tasks.zip(task_context).map(|(tasks, task_context)| { + Rc::new(ResolvedTasks { + templates: tasks.resolve(&task_context).collect(), + position: snapshot + .buffer_snapshot + .anchor_before(Point::new(multibuffer_point.row, tasks.column)), + }) + }); + Some(( + buffer, + CodeActionContents { + actions: code_actions, + tasks: resolved_tasks, + }, + )) + }) + } + pub fn toggle_code_actions( &mut self, action: &ToggleCodeActions, @@ -4853,113 +4937,58 @@ impl Editor { } } drop(context_menu); - let snapshot = self.snapshot(window, cx); + let deployed_from_indicator = action.deployed_from_indicator; let mut task = self.code_actions_task.take(); let action = action.clone(); + cx.spawn_in(window, async move |editor, cx| { while let Some(prev_task) = task { prev_task.await.log_err(); task = editor.update(cx, |this, _| this.code_actions_task.take())?; } - let spawned_test_task = editor.update_in(cx, |editor, window, cx| { - if editor.focus_handle.is_focused(window) { - let multibuffer_point = action - .deployed_from_indicator - .map(|row| DisplayPoint::new(row, 0).to_point(&snapshot)) - .unwrap_or_else(|| editor.selections.newest::(cx).head()); - let (buffer, buffer_row) = snapshot - .buffer_snapshot - .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) - .and_then(|(buffer_snapshot, range)| { - editor - .buffer - .read(cx) - .buffer(buffer_snapshot.remote_id()) - .map(|buffer| (buffer, range.start.row)) - })?; - let (_, code_actions) = editor - .available_code_actions - .clone() - .and_then(|(location, code_actions)| { - let snapshot = location.buffer.read(cx).snapshot(); - let point_range = location.range.to_point(&snapshot); - let point_range = point_range.start.row..=point_range.end.row; - if point_range.contains(&buffer_row) { - Some((location, code_actions)) - } else { - None - } - }) - .unzip(); - let buffer_id = buffer.read(cx).remote_id(); - let tasks = editor - .tasks - .get(&(buffer_id, buffer_row)) - .map(|t| Arc::new(t.to_owned())); - if tasks.is_none() && code_actions.is_none() { - return None; - } - - editor.completion_tasks.clear(); - editor.discard_inline_completion(false, cx); - let task_context = - tasks - .as_ref() - .zip(editor.project.clone()) - .map(|(tasks, project)| { - Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx) - }); - - let debugger_flag = cx.has_flag::(); - - Some(cx.spawn_in(window, async move |editor, cx| { - let task_context = match task_context { - Some(task_context) => task_context.await, - None => None, - }; - let resolved_tasks = - tasks.zip(task_context).map(|(tasks, task_context)| { - Rc::new(ResolvedTasks { - templates: tasks.resolve(&task_context).collect(), - position: snapshot.buffer_snapshot.anchor_before(Point::new( - multibuffer_point.row, - tasks.column, - )), - }) - }); - let spawn_straight_away = resolved_tasks.as_ref().map_or(false, |tasks| { - tasks - .templates - .iter() - .filter(|task| { - if matches!(task.1.task_type(), task::TaskType::Debug(_)) { - debugger_flag - } else { - true - } - }) - .count() - == 1 - }) && code_actions - .as_ref() - .map_or(true, |actions| actions.is_empty()); + let context_menu_task = editor.update_in(cx, |editor, window, cx| { + if !editor.focus_handle.is_focused(window) { + return Some(Task::ready(Ok(()))); + } + let debugger_flag = cx.has_flag::(); + let code_actions_task = editor.prepare_code_actions_task(&action, window, cx); + Some(cx.spawn_in(window, async move |editor, cx| { + if let Some((buffer, code_action_contents)) = code_actions_task.await { + let spawn_straight_away = + code_action_contents.tasks.as_ref().map_or(false, |tasks| { + tasks + .templates + .iter() + .filter(|task| { + if matches!(task.1.task_type(), task::TaskType::Debug(_)) { + debugger_flag + } else { + true + } + }) + .count() + == 1 + }) && code_action_contents + .actions + .as_ref() + .map_or(true, |actions| actions.is_empty()); if let Ok(task) = editor.update_in(cx, |editor, window, cx| { *editor.context_menu.borrow_mut() = Some(CodeContextMenu::CodeActions(CodeActionsMenu { buffer, - actions: CodeActionContents { - tasks: resolved_tasks, - actions: code_actions, - }, + actions: code_action_contents, selected_item: Default::default(), scroll_handle: UniformListScrollHandle::default(), deployed_from_indicator, })); if spawn_straight_away { if let Some(task) = editor.confirm_code_action( - &ConfirmCodeAction { item_ix: Some(0) }, + &ConfirmCodeAction { + item_ix: Some(0), + from_mouse_context_menu: false, + }, window, cx, ) { @@ -4974,12 +5003,12 @@ impl Editor { } else { Ok(()) } - })) - } else { - Some(Task::ready(Ok(()))) - } + } else { + Ok(()) + } + })) })?; - if let Some(task) = spawned_test_task { + if let Some(task) = context_menu_task { task.await?; } @@ -4996,17 +5025,27 @@ impl Editor { ) -> Option>> { self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); - let actions_menu = - if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { - menu + let (action, buffer) = if action.from_mouse_context_menu { + if let Some(menu) = self.mouse_context_menu.take() { + let code_action = menu.code_action?; + let index = action.item_ix?; + let action = code_action.actions.get(index)?; + (action, code_action.buffer) } else { return None; - }; + } + } else { + if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { + let action_ix = action.item_ix.unwrap_or(menu.selected_item); + let action = menu.actions.get(action_ix)?; + let buffer = menu.buffer; + (action, buffer) + } else { + return None; + } + }; - let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); - let action = actions_menu.actions.get(action_ix)?; let title = action.label(); - let buffer = actions_menu.buffer; let workspace = self.workspace()?; match action { @@ -8803,6 +8842,7 @@ impl Editor { self, source, clicked_point, + None, context_menu, window, cx, diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 9450ea4562..bcad4ef3c0 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -1,15 +1,22 @@ use crate::{ - Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DebuggerEvaluateSelectedText, DisplayPoint, - DisplaySnapshot, Editor, FindAllReferences, GoToDeclaration, GoToDefinition, + ConfirmCodeAction, Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DebuggerEvaluateSelectedText, + DisplayPoint, DisplaySnapshot, Editor, FindAllReferences, GoToDeclaration, GoToDefinition, GoToImplementation, GoToTypeDefinition, Paste, Rename, RevealInFileManager, SelectMode, ToDisplayPoint, ToggleCodeActions, actions::{Format, FormatSelections}, + code_context_menus::CodeActionContents, selections_collection::SelectionsCollection, }; +use feature_flags::{Debugger, FeatureFlagAppExt as _}; use gpui::prelude::FluentBuilder; -use gpui::{Context, DismissEvent, Entity, Focusable as _, Pixels, Point, Subscription, Window}; +use gpui::{ + Context, DismissEvent, Entity, FocusHandle, Focusable as _, Pixels, Point, Subscription, Task, + Window, +}; use std::ops::Range; use text::PointUtf16; +use ui::ContextMenu; +use util::ResultExt; use workspace::OpenInTerminal; #[derive(Debug)] @@ -25,12 +32,23 @@ pub enum MenuPosition { }, } +pub struct MouseCodeAction { + pub actions: CodeActionContents, + pub buffer: Entity, +} + pub struct MouseContextMenu { pub(crate) position: MenuPosition, pub(crate) context_menu: Entity, + pub(crate) code_action: Option, _subscription: Subscription, } +enum CodeActionLoadState { + Loading, + Loaded(CodeActionContents), +} + impl std::fmt::Debug for MouseContextMenu { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MouseContextMenu") @@ -45,6 +63,7 @@ impl MouseContextMenu { editor: &mut Editor, source: multi_buffer::Anchor, position: Point, + code_action: Option, context_menu: Entity, window: &mut Window, cx: &mut Context, @@ -63,6 +82,7 @@ impl MouseContextMenu { return Some(MouseContextMenu::new( menu_position, context_menu, + code_action, window, cx, )); @@ -71,6 +91,7 @@ impl MouseContextMenu { pub(crate) fn new( position: MenuPosition, context_menu: Entity, + code_action: Option, window: &mut Window, cx: &mut Context, ) -> Self { @@ -91,6 +112,7 @@ impl MouseContextMenu { Self { position, context_menu, + code_action, _subscription, } } @@ -129,13 +151,13 @@ pub fn deploy_context_menu( let display_map = editor.selections.display_map(cx); let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right); - let context_menu = if let Some(custom) = editor.custom_context_menu.take() { + if let Some(custom) = editor.custom_context_menu.take() { let menu = custom(editor, point, window, cx); editor.custom_context_menu = Some(custom); let Some(menu) = menu else { return; }; - menu + set_context_menu(editor, menu, source_anchor, position, None, window, cx); } else { // Don't show the context menu if there isn't a project associated with this editor let Some(project) = editor.project.clone() else { @@ -174,74 +196,223 @@ pub fn deploy_context_menu( !filter.is_hidden(&DebuggerEvaluateSelectedText) }); - ui::ContextMenu::build(window, cx, |menu, _window, _cx| { - let builder = menu - .on_blur_subscription(Subscription::new(|| {})) - .when(evaluate_selection && has_selections, |builder| { - builder - .action("Evaluate Selection", Box::new(DebuggerEvaluateSelectedText)) - .separator() - }) - .action("Go to Definition", Box::new(GoToDefinition)) - .action("Go to Declaration", Box::new(GoToDeclaration)) - .action("Go to Type Definition", Box::new(GoToTypeDefinition)) - .action("Go to Implementation", Box::new(GoToImplementation)) - .action("Find All References", Box::new(FindAllReferences)) - .separator() - .action("Rename Symbol", Box::new(Rename)) - .action("Format Buffer", Box::new(Format)) - .when(has_selections, |cx| { - cx.action("Format Selections", Box::new(FormatSelections)) - }) - .action( - "Code Actions", - Box::new(ToggleCodeActions { - deployed_from_indicator: None, - }), - ) - .separator() - .action("Cut", Box::new(Cut)) - .action("Copy", Box::new(Copy)) - .action("Copy and trim", Box::new(CopyAndTrim)) - .action("Paste", Box::new(Paste)) - .separator() - .map(|builder| { - let reveal_in_finder_label = if cfg!(target_os = "macos") { - "Reveal in Finder" - } else { - "Reveal in File Manager" - }; - const OPEN_IN_TERMINAL_LABEL: &str = "Open in Terminal"; - if has_reveal_target { - builder - .action(reveal_in_finder_label, Box::new(RevealInFileManager)) - .action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal)) - } else { - builder - .disabled_action(reveal_in_finder_label, Box::new(RevealInFileManager)) - .disabled_action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal)) - } - }) - .map(|builder| { - const COPY_PERMALINK_LABEL: &str = "Copy Permalink"; - if has_git_repo { - builder.action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine)) - } else { - builder.disabled_action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine)) - } - }); - match focus { - Some(focus) => builder.context(focus), - None => builder, - } - }) - }; + let menu = build_context_menu( + focus, + has_selections, + has_reveal_target, + has_git_repo, + evaluate_selection, + Some(CodeActionLoadState::Loading), + window, + cx, + ); + set_context_menu(editor, menu, source_anchor, position, None, window, cx); + + let mut actions_task = editor.code_actions_task.take(); + cx.spawn_in(window, async move |editor, cx| { + while let Some(prev_task) = actions_task { + prev_task.await.log_err(); + actions_task = editor.update(cx, |this, _| this.code_actions_task.take())?; + } + let action = ToggleCodeActions { + deployed_from_indicator: Some(point.row()), + }; + let context_menu_task = editor.update_in(cx, |editor, window, cx| { + let code_actions_task = editor.prepare_code_actions_task(&action, window, cx); + Some(cx.spawn_in(window, async move |editor, cx| { + let code_action_result = code_actions_task.await; + if let Ok(editor_task) = editor.update_in(cx, |editor, window, cx| { + let Some(mouse_context_menu) = editor.mouse_context_menu.take() else { + return Task::ready(Ok::<_, anyhow::Error>(())); + }; + if mouse_context_menu + .context_menu + .focus_handle(cx) + .contains_focused(window, cx) + { + window.focus(&editor.focus_handle(cx)); + } + drop(mouse_context_menu); + let (state, code_action) = + if let Some((buffer, actions)) = code_action_result { + ( + CodeActionLoadState::Loaded(actions.clone()), + Some(MouseCodeAction { actions, buffer }), + ) + } else { + ( + CodeActionLoadState::Loaded(CodeActionContents::default()), + None, + ) + }; + let menu = build_context_menu( + window.focused(cx), + has_selections, + has_reveal_target, + has_git_repo, + evaluate_selection, + Some(state), + window, + cx, + ); + set_context_menu( + editor, + menu, + source_anchor, + position, + code_action, + window, + cx, + ); + Task::ready(Ok(())) + }) { + editor_task.await + } else { + Ok(()) + } + })) + })?; + if let Some(task) = context_menu_task { + task.await?; + } + Ok::<_, anyhow::Error>(()) + }) + .detach_and_log_err(cx); + }; +} + +fn build_context_menu( + focus: Option, + has_selections: bool, + has_reveal_target: bool, + has_git_repo: bool, + evaluate_selection: bool, + code_action_load_state: Option, + window: &mut Window, + cx: &mut Context, +) -> Entity { + ui::ContextMenu::build(window, cx, |menu, _window, cx| { + let menu = menu + .on_blur_subscription(Subscription::new(|| {})) + .when_some(code_action_load_state, |menu, state| { + match state { + CodeActionLoadState::Loading => menu.disabled_action( + "Loading code actions...", + Box::new(ConfirmCodeAction { + item_ix: None, + from_mouse_context_menu: true, + }), + ), + CodeActionLoadState::Loaded(actions) => { + if actions.is_empty() { + menu.disabled_action( + "No code actions available", + Box::new(ConfirmCodeAction { + item_ix: None, + from_mouse_context_menu: true, + }), + ) + } else { + actions + .iter() + .filter(|action| { + if action + .as_task() + .map(|task| { + matches!(task.task_type(), task::TaskType::Debug(_)) + }) + .unwrap_or(false) + { + cx.has_flag::() + } else { + true + } + }) + .enumerate() + .fold(menu, |menu, (ix, action)| { + menu.action( + action.label(), + Box::new(ConfirmCodeAction { + item_ix: Some(ix), + from_mouse_context_menu: true, + }), + ) + }) + } + } + } + .separator() + }) + .when(evaluate_selection && has_selections, |builder| { + builder + .action("Evaluate Selection", Box::new(DebuggerEvaluateSelectedText)) + .separator() + }) + .action("Go to Definition", Box::new(GoToDefinition)) + .action("Go to Declaration", Box::new(GoToDeclaration)) + .action("Go to Type Definition", Box::new(GoToTypeDefinition)) + .action("Go to Implementation", Box::new(GoToImplementation)) + .action("Find All References", Box::new(FindAllReferences)) + .separator() + .action("Rename Symbol", Box::new(Rename)) + .action("Format Buffer", Box::new(Format)) + .when(has_selections, |cx| { + cx.action("Format Selections", Box::new(FormatSelections)) + }) + .separator() + .action("Cut", Box::new(Cut)) + .action("Copy", Box::new(Copy)) + .action("Copy and trim", Box::new(CopyAndTrim)) + .action("Paste", Box::new(Paste)) + .separator() + .map(|builder| { + let reveal_in_finder_label = if cfg!(target_os = "macos") { + "Reveal in Finder" + } else { + "Reveal in File Manager" + }; + const OPEN_IN_TERMINAL_LABEL: &str = "Open in Terminal"; + if has_reveal_target { + builder + .action(reveal_in_finder_label, Box::new(RevealInFileManager)) + .action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal)) + } else { + builder + .disabled_action(reveal_in_finder_label, Box::new(RevealInFileManager)) + .disabled_action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal)) + } + }) + .map(|builder| { + const COPY_PERMALINK_LABEL: &str = "Copy Permalink"; + if has_git_repo { + builder.action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine)) + } else { + builder.disabled_action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine)) + } + }); + match focus { + Some(focus) => menu.context(focus), + None => menu, + } + }) +} + +fn set_context_menu( + editor: &mut Editor, + context_menu: Entity, + source_anchor: multi_buffer::Anchor, + position: Option>, + code_action: Option, + window: &mut Window, + cx: &mut Context, +) { editor.mouse_context_menu = match position { Some(position) => MouseContextMenu::pinned_to_editor( editor, source_anchor, position, + code_action, context_menu, window, cx, @@ -255,6 +426,7 @@ pub fn deploy_context_menu( Some(MouseContextMenu::new( menu_position, context_menu, + code_action, window, cx, )) From 62787614601e71c4817ca1fe5bb52ac1140a1f33 Mon Sep 17 00:00:00 2001 From: 5brian Date: Mon, 14 Apr 2025 09:56:24 -0400 Subject: [PATCH 36/75] agent: Fix expand message editor while not focused (#28650) Allow expanding the message editor while the agent panel is not focused, right now there is no effect when you use the button from another focus Release Notes: - N/A --- crates/agent/src/assistant_panel.rs | 14 ++++++++++++-- crates/agent/src/message_editor.rs | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 0d8682abf5..3e53ece36c 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -44,8 +44,8 @@ use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; use crate::thread_history::{PastContext, PastThread, ThreadHistory}; use crate::thread_store::ThreadStore; use crate::{ - AgentDiff, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, OpenHistory, ThreadEvent, ToggleContextPicker, + AgentDiff, ExpandMessageEditor, InlineAssistant, NewTextThread, NewThread, + OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent, ToggleContextPicker, }; pub fn init(cx: &mut App) { @@ -90,6 +90,16 @@ pub fn init(cx: &mut App) { let thread = panel.read(cx).thread.read(cx).thread().clone(); AgentDiff::deploy_in_workspace(thread, workspace, window, cx); } + }) + .register_action(|workspace, _: &ExpandMessageEditor, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| { + panel.message_editor.update(cx, |editor, cx| { + editor.expand_message_editor(&ExpandMessageEditor, window, cx); + }); + }); + } }); }, ) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index bdc7c67cff..3b8881f2d8 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -157,7 +157,7 @@ impl MessageEditor { cx.notify(); } - fn expand_message_editor( + pub fn expand_message_editor( &mut self, _: &ExpandMessageEditor, _window: &mut Window, From 0eb0a3c7dc886a58407ec4c872c7f0daf46814ee Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 14 Apr 2025 11:02:55 -0300 Subject: [PATCH 37/75] agent: Move focus to the message editor after going back (#28686) When you hit the back button in the agent panel toolbar, we were returning the focus to the buffer instead to the panel's message editor, which is likely where you want to be after quickly checking history or settings. Release Notes: - N/A --- crates/agent/src/assistant_panel.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 3e53ece36c..b5dcee9dc2 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -569,6 +569,7 @@ impl AssistantPanel { ActiveView::Configuration | ActiveView::History => { self.active_view = ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx); + self.message_editor.focus_handle(cx).focus(window); cx.notify(); } _ => {} From 4a57664c7fc5be85d7b43df4dd5e543fdc7aff42 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 14 Apr 2025 10:17:07 -0400 Subject: [PATCH 38/75] zlog: Use zlog as default log implementation (#28612) Still TODO: - [x] Remove old log implementations - [x] More cleanup - [x] Verify atomic/lock logic - [x] More tests - [ ] ??? Ansi coloring when logging to stdout Release Notes: - N/A --- Cargo.lock | 3 +- crates/zed/Cargo.toml | 1 - crates/zed/src/logger.rs | 122 ----- crates/zed/src/main.rs | 12 +- crates/zlog/Cargo.toml | 4 + crates/zlog/src/filter.rs | 568 +++++++++++++++++++ crates/zlog/src/sink.rs | 233 ++++++++ crates/zlog/src/zlog.rs | 633 ++-------------------- crates/zlog_settings/src/zlog_settings.rs | 2 +- 9 files changed, 872 insertions(+), 706 deletions(-) delete mode 100644 crates/zed/src/logger.rs create mode 100644 crates/zlog/src/filter.rs create mode 100644 crates/zlog/src/sink.rs diff --git a/Cargo.lock b/Cargo.lock index 9af249804c..e9e2fb8fdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18206,7 +18206,6 @@ dependencies = [ "settings", "settings_ui", "shellexpand 2.1.2", - "simplelog", "smol", "snippet_provider", "snippets_ui", @@ -18555,7 +18554,9 @@ name = "zlog" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "log", + "tempfile", "workspace-hack", ] diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 6c920d07d8..d167739f3c 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -108,7 +108,6 @@ session.workspace = true settings.workspace = true settings_ui.workspace = true shellexpand.workspace = true -simplelog.workspace = true smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true diff --git a/crates/zed/src/logger.rs b/crates/zed/src/logger.rs deleted file mode 100644 index 5a889424d0..0000000000 --- a/crates/zed/src/logger.rs +++ /dev/null @@ -1,122 +0,0 @@ -use chrono::Offset; -use env_logger::Builder; -use log::LevelFilter; -use simplelog::ConfigBuilder; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use time::UtcOffset; - -pub fn init_logger() { - let level = LevelFilter::Info; - - // Prevent log file from becoming too large. - const KIB: u64 = 1024; - const MIB: u64 = 1024 * KIB; - const MAX_LOG_BYTES: u64 = MIB; - if std::fs::metadata(paths::log_file()).map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES) - { - let _ = std::fs::rename(paths::log_file(), paths::old_log_file()); - } - - match LogWriter::new(MAX_LOG_BYTES) { - Ok(writer) => { - let mut config_builder = ConfigBuilder::new(); - - config_builder.set_time_format_rfc3339(); - let local_offset = chrono::Local::now().offset().fix().local_minus_utc(); - if let Ok(offset) = UtcOffset::from_whole_seconds(local_offset) { - config_builder.set_time_offset(offset); - } - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - { - config_builder.add_filter_ignore_str("zbus"); - config_builder.add_filter_ignore_str("blade_graphics::hal::resource"); - config_builder.add_filter_ignore_str("naga::back::spv::writer"); - } - - let config = config_builder.build(); - simplelog::WriteLogger::init(level, config, writer) - .expect("could not initialize logger"); - } - Err(err) => { - init_stdout_logger(); - log::error!( - "could not open log file, defaulting to stdout logging: {}", - err - ); - } - } -} - -pub fn init_stdout_logger() { - Builder::new() - .parse_default_env() - .format(|buf, record| { - use env_logger::fmt::style::{AnsiColor, Style}; - - let subtle = Style::new().fg_color(Some(AnsiColor::BrightBlack.into())); - write!(buf, "{subtle}[{subtle:#}")?; - write!( - buf, - "{} ", - chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z") - )?; - let level_style = buf.default_level_style(record.level()); - write!(buf, "{level_style}{:<5}{level_style:#}", record.level())?; - if let Some(path) = record.module_path() { - write!(buf, " {path}")?; - } - write!(buf, "{subtle}]{subtle:#}")?; - writeln!(buf, " {}", record.args()) - }) - .init(); -} - -struct LogWriter { - file: File, - max_size: u64, - current_size: u64, -} - -impl LogWriter { - fn new(max_size: u64) -> io::Result { - let file = OpenOptions::new() - .create(true) - .append(true) - .open(paths::log_file())?; - let current_size = file.metadata()?.len(); - - Ok(LogWriter { - file, - max_size, - current_size, - }) - } - - fn replace(&mut self) -> io::Result<()> { - self.file.sync_all()?; - fs::rename(paths::log_file(), paths::old_log_file())?; - self.file = OpenOptions::new() - .create(true) - .append(true) - .open(paths::log_file())?; - self.current_size = 0; - Ok(()) - } -} - -impl Write for LogWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - if self.current_size + buf.len() as u64 > self.max_size { - self.replace()?; - } - let bytes = self.file.write(buf)?; - self.current_size += bytes as u64; - Ok(bytes) - } - - fn flush(&mut self) -> io::Result<()> { - self.file.flush() - } -} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f087f73194..98a3ecbd26 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1,7 +1,6 @@ // Disable command line from opening on release mode #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod logger; mod reliability; mod zed; @@ -28,7 +27,6 @@ use prompt_store::PromptBuilder; use reqwest_client::ReqwestClient; use assets::Assets; -use logger::{init_logger, init_stdout_logger}; use node_runtime::{NodeBinaryOptions, NodeRuntime}; use parking_lot::Mutex; use project::project_settings::ProjectSettings; @@ -195,11 +193,15 @@ fn main() { return; } - zlog::init_from_env(); + zlog::init(); if stdout_is_a_pty() { - init_stdout_logger(); + zlog::init_output_stdout(); } else { - init_logger(); + let result = zlog::init_output_file(paths::log_file(), Some(paths::old_log_file())); + if let Err(err) = result { + eprintln!("Could not open log file: {}... Defaulting to stdout", err); + zlog::init_output_stdout(); + }; } log::info!("========== starting zed =========="); diff --git a/crates/zlog/Cargo.toml b/crates/zlog/Cargo.toml index b64b72633d..d0632d14f2 100644 --- a/crates/zlog/Cargo.toml +++ b/crates/zlog/Cargo.toml @@ -15,6 +15,10 @@ path = "src/zlog.rs" default = [] [dependencies] +chrono.workspace = true log.workspace = true workspace-hack.workspace = true anyhow.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/zlog/src/filter.rs b/crates/zlog/src/filter.rs new file mode 100644 index 0000000000..255f17a6d4 --- /dev/null +++ b/crates/zlog/src/filter.rs @@ -0,0 +1,568 @@ +use std::{ + collections::{HashMap, VecDeque}, + hash::{DefaultHasher, Hasher}, + sync::{ + OnceLock, RwLock, + atomic::{AtomicU8, Ordering}, + }, + usize, +}; + +use crate::{SCOPE_DEPTH_MAX, SCOPE_STRING_SEP_STR, Scope, ScopeAlloc, env_config}; + +use log; + +static ENV_FILTER: OnceLock = OnceLock::new(); +static SCOPE_MAP: RwLock> = RwLock::new(None); +struct GlobalScopeMap { + map: ScopeMap, + hash: u64, +} + +const LEVEL_ENABLED_MAX_DEFAULT: log::LevelFilter = log::LevelFilter::Info; +/// The maximum log level of verbosity that is enabled by default. +/// All messages more verbose than this level will be discarded +/// by default unless specially configured. +/// +/// This is used instead of the `log::max_level` as we need to tell the `log` +/// crate that the max level is everything, so that we can dynamically enable +/// logs that are more verbose than this level without the `log` crate throwing +/// them away before we see them +static mut LEVEL_ENABLED_MAX_STATIC: log::LevelFilter = LEVEL_ENABLED_MAX_DEFAULT; + +/// A cache of the true maximum log level that _could_ be printed. This is based +/// on the maximally verbose level that is configured by the user, and is used +/// to filter out logs more verbose than any configured level. +/// +/// E.g. if `LEVEL_ENABLED_MAX_STATIC `is 'info' but a user has configured some +/// scope to print at a `debug` level, then this will be `debug`, and all +/// `trace` logs will be discarded. +/// Therefore, it should always be `>= LEVEL_ENABLED_MAX_STATIC` +// PERF: this doesn't need to be an atomic, we don't actually care about race conditions here +static LEVEL_ENABLED_MAX_CONFIG: AtomicU8 = AtomicU8::new(LEVEL_ENABLED_MAX_DEFAULT as u8); + +pub fn init_env_filter(filter: env_config::EnvFilter) { + if let Some(level_max) = filter.level_global { + unsafe { LEVEL_ENABLED_MAX_STATIC = level_max } + } + if ENV_FILTER.set(filter).is_err() { + panic!("Environment filter cannot be initialized twice"); + } +} + +pub fn is_possibly_enabled_level(level: log::Level) -> bool { + return LEVEL_ENABLED_MAX_CONFIG.load(Ordering::Relaxed) <= level as u8; +} + +pub fn is_scope_enabled(scope: &Scope, level: log::Level) -> bool { + if level <= unsafe { LEVEL_ENABLED_MAX_STATIC } { + // [FAST PATH] + // if the message is at or below the minimum printed log level + // (where error < warn < info etc) then always enable + return true; + } + if !is_possibly_enabled_level(level) { + // [FAST PATH PT. 2] + // if the message is above the maximum enabled log level + // (where error < warn < info etc) then disable without checking + // scope map + return false; + } + let global_scope_map = SCOPE_MAP.read().unwrap_or_else(|err| { + SCOPE_MAP.clear_poison(); + return err.into_inner(); + }); + + let Some(GlobalScopeMap { map, .. }) = global_scope_map.as_ref() else { + // on failure, return false because it's not <= LEVEL_ENABLED_MAX_STATIC + return false; + }; + + if map.is_empty() { + // if no scopes are enabled, return false because it's not <= LEVEL_ENABLED_MAX_STATIC + return false; + } + let enabled_status = map.is_enabled(&scope, level); + return match enabled_status { + // if it isn't configured, then it it's disabled because it's not <= LEVEL_ENABLED_MAX_STATIC + EnabledStatus::NotConfigured => false, + EnabledStatus::Enabled => true, + EnabledStatus::Disabled => false, + }; +} + +fn hash_scope_map_settings(map: &HashMap) -> u64 { + let mut hasher = DefaultHasher::new(); + let mut items = map.iter().collect::>(); + items.sort(); + for (key, value) in items { + Hasher::write(&mut hasher, key.as_bytes()); + Hasher::write(&mut hasher, value.as_bytes()); + } + return hasher.finish(); +} + +pub(crate) fn refresh() { + refresh_from_settings(&HashMap::default()); +} + +pub fn refresh_from_settings(settings: &HashMap) { + let hash_old = { + SCOPE_MAP + .read() + .unwrap_or_else(|err| { + SCOPE_MAP.clear_poison(); + err.into_inner() + }) + .as_ref() + .map(|scope_map| scope_map.hash) + }; + let hash_new = hash_scope_map_settings(settings); + if hash_old == Some(hash_new) { + return; + } + let env_config = ENV_FILTER.get(); + let map_new = ScopeMap::new_from_settings_and_env(settings, env_config); + let mut level_enabled_max = unsafe { LEVEL_ENABLED_MAX_STATIC }; + for entry in &map_new.entries { + if let Some(level) = entry.enabled { + level_enabled_max = level_enabled_max.max(level.to_level_filter()); + } + } + LEVEL_ENABLED_MAX_CONFIG.store(level_enabled_max as u8, Ordering::Release); + + let mut global_map = SCOPE_MAP.write().unwrap_or_else(|err| { + SCOPE_MAP.clear_poison(); + err.into_inner() + }); + global_map.replace(GlobalScopeMap { + map: map_new, + hash: hash_new, + }); +} + +fn level_from_level_str(level_str: &String) -> Option { + let level = match level_str.to_ascii_lowercase().as_str() { + "" => log::Level::Trace, + "trace" => log::Level::Trace, + "debug" => log::Level::Debug, + "info" => log::Level::Info, + "warn" => log::Level::Warn, + "error" => log::Level::Error, + "off" | "disable" | "no" | "none" | "disabled" => { + crate::warn!( + "Invalid log level \"{level_str}\", set to error to disable non-error logging. Defaulting to error" + ); + log::Level::Error + } + _ => { + crate::warn!("Invalid log level \"{level_str}\", ignoring"); + return None; + } + }; + return Some(level); +} + +fn scope_alloc_from_scope_str(scope_str: &String) -> Option { + let mut scope_buf = [""; SCOPE_DEPTH_MAX]; + let mut index = 0; + let mut scope_iter = scope_str.split(SCOPE_STRING_SEP_STR); + while index < SCOPE_DEPTH_MAX { + let Some(scope) = scope_iter.next() else { + break; + }; + if scope == "" { + continue; + } + scope_buf[index] = scope; + index += 1; + } + if index == 0 { + return None; + } + if let Some(_) = scope_iter.next() { + crate::warn!( + "Invalid scope key, too many nested scopes: '{scope_str}'. Max depth is {SCOPE_DEPTH_MAX}", + ); + return None; + } + let scope = scope_buf.map(|s| s.to_string()); + return Some(scope); +} + +pub struct ScopeMap { + entries: Vec, + root_count: usize, +} + +pub struct ScopeMapEntry { + scope: String, + enabled: Option, + descendants: std::ops::Range, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnabledStatus { + Enabled, + Disabled, + NotConfigured, +} + +impl ScopeMap { + pub fn new_from_settings_and_env( + items_input_map: &HashMap, + env_config: Option<&env_config::EnvFilter>, + ) -> Self { + let mut items = Vec::with_capacity( + items_input_map.len() + env_config.map_or(0, |c| c.directive_names.len()), + ); + if let Some(env_filter) = env_config { + // TODO: parse on load instead of every reload + items.extend( + env_filter + .directive_names + .iter() + .zip(env_filter.directive_levels.iter()) + .filter_map(|(scope, level_filter)| { + if items_input_map.get(scope).is_some() { + return None; + } + let scope = scope_alloc_from_scope_str(scope)?; + // TODO: use level filters instead of scopes in scope map + let level = level_filter.to_level()?; + + Some((scope, level)) + }), + ); + } + items.extend( + items_input_map + .into_iter() + .filter_map(|(scope_str, level_str)| { + let scope = scope_alloc_from_scope_str(&scope_str)?; + let level = level_from_level_str(&level_str)?; + return Some((scope, level)); + }), + ); + + items.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut this = Self { + entries: Vec::with_capacity(items.len() * SCOPE_DEPTH_MAX), + root_count: 0, + }; + + let items_count = items.len(); + + struct ProcessQueueEntry { + parent_index: usize, + depth: usize, + items_range: std::ops::Range, + } + let mut process_queue = VecDeque::new(); + process_queue.push_back(ProcessQueueEntry { + parent_index: usize::MAX, + depth: 0, + items_range: 0..items_count, + }); + + let empty_range = 0..0; + + while let Some(process_entry) = process_queue.pop_front() { + let ProcessQueueEntry { + items_range, + depth, + parent_index, + } = process_entry; + let mut cursor = items_range.start; + let res_entries_start = this.entries.len(); + while cursor < items_range.end { + let sub_items_start = cursor; + cursor += 1; + let scope_name = &items[sub_items_start].0[depth]; + while cursor < items_range.end && &items[cursor].0[depth] == scope_name { + cursor += 1; + } + let sub_items_end = cursor; + if scope_name == "" { + assert_eq!(sub_items_start + 1, sub_items_end); + assert_ne!(depth, 0); + assert_ne!(parent_index, usize::MAX); + assert!(this.entries[parent_index].enabled.is_none()); + this.entries[parent_index].enabled = Some(items[sub_items_start].1); + continue; + } + let is_valid_scope = scope_name != ""; + let is_last = depth + 1 == SCOPE_DEPTH_MAX || !is_valid_scope; + let mut enabled = None; + if is_last { + assert_eq!( + sub_items_start + 1, + sub_items_end, + "Expected one item: got: {:?}", + &items[items_range.clone()] + ); + enabled = Some(items[sub_items_start].1); + } else { + let entry_index = this.entries.len(); + process_queue.push_back(ProcessQueueEntry { + items_range: sub_items_start..sub_items_end, + parent_index: entry_index, + depth: depth + 1, + }); + } + this.entries.push(ScopeMapEntry { + scope: scope_name.to_owned(), + enabled, + descendants: empty_range.clone(), + }); + } + let res_entries_end = this.entries.len(); + if parent_index != usize::MAX { + this.entries[parent_index].descendants = res_entries_start..res_entries_end; + } else { + this.root_count = res_entries_end; + } + } + + return this; + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn is_enabled(&self, scope: &[S; SCOPE_DEPTH_MAX], level: log::Level) -> EnabledStatus + where + S: AsRef, + { + let mut enabled = None; + let mut cur_range = &self.entries[0..self.root_count]; + let mut depth = 0; + + 'search: while !cur_range.is_empty() + && depth < SCOPE_DEPTH_MAX + && scope[depth].as_ref() != "" + { + for entry in cur_range { + if entry.scope == scope[depth].as_ref() { + // note: + enabled = entry.enabled.or(enabled); + cur_range = &self.entries[entry.descendants.clone()]; + depth += 1; + continue 'search; + } + } + break 'search; + } + + return enabled.map_or(EnabledStatus::NotConfigured, |level_enabled| { + if level <= level_enabled { + EnabledStatus::Enabled + } else { + EnabledStatus::Disabled + } + }); + } +} + +#[cfg(test)] +mod tests { + use crate::private::scope_new; + + use super::*; + + fn scope_map_from_keys(kv: &[(&str, &str)]) -> ScopeMap { + let hash_map: HashMap = kv + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + ScopeMap::new_from_settings_and_env(&hash_map, None) + } + + #[test] + fn test_initialization() { + let map = scope_map_from_keys(&[("a.b.c.d", "trace")]); + assert_eq!(map.root_count, 1); + assert_eq!(map.entries.len(), 4); + + let map = scope_map_from_keys(&[]); + assert_eq!(map.root_count, 0); + assert_eq!(map.entries.len(), 0); + + let map = scope_map_from_keys(&[("", "trace")]); + assert_eq!(map.root_count, 0); + assert_eq!(map.entries.len(), 0); + + let map = scope_map_from_keys(&[("foo..bar", "trace")]); + assert_eq!(map.root_count, 1); + assert_eq!(map.entries.len(), 2); + + let map = scope_map_from_keys(&[ + ("a.b.c.d", "trace"), + ("e.f.g.h", "debug"), + ("i.j.k.l", "info"), + ("m.n.o.p", "warn"), + ("q.r.s.t", "error"), + ]); + assert_eq!(map.root_count, 5); + assert_eq!(map.entries.len(), 20); + assert_eq!(map.entries[0].scope, "a"); + assert_eq!(map.entries[1].scope, "e"); + assert_eq!(map.entries[2].scope, "i"); + assert_eq!(map.entries[3].scope, "m"); + assert_eq!(map.entries[4].scope, "q"); + } + + fn scope_from_scope_str(scope_str: &'static str) -> Scope { + let mut scope_buf = [""; SCOPE_DEPTH_MAX]; + let mut index = 0; + let mut scope_iter = scope_str.split(SCOPE_STRING_SEP_STR); + while index < SCOPE_DEPTH_MAX { + let Some(scope) = scope_iter.next() else { + break; + }; + if scope == "" { + continue; + } + scope_buf[index] = scope; + index += 1; + } + assert_ne!(index, 0); + assert!(scope_iter.next().is_none()); + return scope_buf; + } + + #[test] + fn test_is_enabled() { + let map = scope_map_from_keys(&[ + ("a.b.c.d", "trace"), + ("e.f.g.h", "debug"), + ("i.j.k.l", "info"), + ("m.n.o.p", "warn"), + ("q.r.s.t", "error"), + ]); + use log::Level; + assert_eq!( + map.is_enabled(&scope_from_scope_str("a.b.c.d"), Level::Trace), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("a.b.c.d"), Level::Debug), + EnabledStatus::Enabled + ); + + assert_eq!( + map.is_enabled(&scope_from_scope_str("e.f.g.h"), Level::Debug), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("e.f.g.h"), Level::Info), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("e.f.g.h"), Level::Trace), + EnabledStatus::Disabled + ); + + assert_eq!( + map.is_enabled(&scope_from_scope_str("i.j.k.l"), Level::Info), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("i.j.k.l"), Level::Warn), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("i.j.k.l"), Level::Debug), + EnabledStatus::Disabled + ); + + assert_eq!( + map.is_enabled(&scope_from_scope_str("m.n.o.p"), Level::Warn), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("m.n.o.p"), Level::Error), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("m.n.o.p"), Level::Info), + EnabledStatus::Disabled + ); + + assert_eq!( + map.is_enabled(&scope_from_scope_str("q.r.s.t"), Level::Error), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_from_scope_str("q.r.s.t"), Level::Warn), + EnabledStatus::Disabled + ); + } + + fn scope_map_from_keys_and_env(kv: &[(&str, &str)], env: &env_config::EnvFilter) -> ScopeMap { + let hash_map: HashMap = kv + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + ScopeMap::new_from_settings_and_env(&hash_map, Some(env)) + } + + #[test] + fn test_initialization_with_env() { + let env_filter = env_config::parse("a.b=debug,u=error").unwrap(); + let map = scope_map_from_keys_and_env(&[], &env_filter); + assert_eq!(map.root_count, 2); + assert_eq!(map.entries.len(), 3); + assert_eq!( + map.is_enabled(&scope_new(&["a"]), log::Level::Debug), + EnabledStatus::NotConfigured + ); + assert_eq!( + map.is_enabled(&scope_new(&["a", "b"]), log::Level::Debug), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_new(&["a", "b", "c"]), log::Level::Trace), + EnabledStatus::Disabled + ); + + let env_filter = env_config::parse("a.b=debug,e.f.g.h=trace,u=error").unwrap(); + let map = scope_map_from_keys_and_env( + &[ + ("a.b.c.d", "trace"), + ("e.f.g.h", "debug"), + ("i.j.k.l", "info"), + ("m.n.o.p", "warn"), + ("q.r.s.t", "error"), + ], + &env_filter, + ); + assert_eq!(map.root_count, 6); + assert_eq!(map.entries.len(), 21); + assert_eq!(map.entries[0].scope, "a"); + assert_eq!(map.entries[1].scope, "e"); + assert_eq!(map.entries[2].scope, "i"); + assert_eq!(map.entries[3].scope, "m"); + assert_eq!(map.entries[4].scope, "q"); + assert_eq!(map.entries[5].scope, "u"); + assert_eq!( + map.is_enabled(&scope_new(&["a", "b", "c", "d"]), log::Level::Trace), + EnabledStatus::Enabled + ); + assert_eq!( + map.is_enabled(&scope_new(&["a", "b", "c"]), log::Level::Trace), + EnabledStatus::Disabled + ); + assert_eq!( + map.is_enabled(&scope_new(&["u", "v"]), log::Level::Warn), + EnabledStatus::Disabled + ); + // settings override env + assert_eq!( + map.is_enabled(&scope_new(&["e", "f", "g", "h"]), log::Level::Trace), + EnabledStatus::Disabled, + ); + } +} diff --git a/crates/zlog/src/sink.rs b/crates/zlog/src/sink.rs new file mode 100644 index 0000000000..a04973f3e3 --- /dev/null +++ b/crates/zlog/src/sink.rs @@ -0,0 +1,233 @@ +use std::{ + fs, + io::{self, Write}, + path::PathBuf, + sync::{ + Mutex, OnceLock, + atomic::{AtomicU64, Ordering}, + }, +}; + +use crate::{SCOPE_STRING_SEP_CHAR, Scope}; + +/// Whether stdout output is enabled. +static mut ENABLED_SINKS_STDOUT: bool = false; + +/// Is Some(file) if file output is enabled. +static ENABLED_SINKS_FILE: Mutex> = Mutex::new(None); +static SINK_FILE_PATH: OnceLock<&'static PathBuf> = OnceLock::new(); +static SINK_FILE_PATH_ROTATE: OnceLock<&'static PathBuf> = OnceLock::new(); +/// Atomic counter for the size of the log file in bytes. +// TODO: make non-atomic if writing single threaded +static SINK_FILE_SIZE_BYTES: AtomicU64 = AtomicU64::new(0); +/// Maximum size of the log file before it will be rotated, in bytes. +const SINK_FILE_SIZE_BYTES_MAX: u64 = 1024 * 1024; // 1 MB + +pub fn init_output_stdout() { + unsafe { + ENABLED_SINKS_STDOUT = true; + } +} + +pub fn init_output_file( + path: &'static PathBuf, + path_rotate: Option<&'static PathBuf>, +) -> io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + + SINK_FILE_PATH + .set(path) + .expect("Init file output should only be called once"); + if let Some(path_rotate) = path_rotate { + SINK_FILE_PATH_ROTATE + .set(path_rotate) + .expect("Init file output should only be called once"); + } + + let mut enabled_sinks_file = ENABLED_SINKS_FILE + .try_lock() + .expect("Log file lock is available during init"); + + let size_bytes = file.metadata().map_or(0, |metadata| metadata.len()); + if size_bytes >= SINK_FILE_SIZE_BYTES_MAX { + rotate_log_file(&mut file, Some(path), path_rotate, &SINK_FILE_SIZE_BYTES); + } else { + SINK_FILE_SIZE_BYTES.store(size_bytes, Ordering::Relaxed); + } + + *enabled_sinks_file = Some(file); + + Ok(()) +} + +const LEVEL_OUTPUT_STRINGS: [&str; 6] = [ + " ", // nop: ERROR = 1 + "ERROR", // + "WARN ", // + "INFO ", // + "DEBUG", // + "TRACE", // +]; + +pub fn submit(record: Record) { + if unsafe { ENABLED_SINKS_STDOUT } { + let mut stdout = std::io::stdout().lock(); + _ = writeln!( + &mut stdout, + "{} {} [{}] {}", + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z"), + LEVEL_OUTPUT_STRINGS[record.level as usize], + ScopeFmt(record.scope), + record.message + ); + } + let mut file = ENABLED_SINKS_FILE.lock().unwrap_or_else(|handle| { + ENABLED_SINKS_FILE.clear_poison(); + handle.into_inner() + }); + if let Some(file) = file.as_mut() { + struct SizedWriter<'a> { + file: &'a mut std::fs::File, + written: u64, + } + impl io::Write for SizedWriter<'_> { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.file.write(buf)?; + self.written += buf.len() as u64; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } + } + let file_size_bytes = { + let mut writer = SizedWriter { file, written: 0 }; + _ = writeln!( + &mut writer, + "{} {} [{}] {}", + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z"), + LEVEL_OUTPUT_STRINGS[record.level as usize], + ScopeFmt(record.scope), + record.message + ); + SINK_FILE_SIZE_BYTES.fetch_add(writer.written, Ordering::Relaxed) + writer.written + }; + if file_size_bytes > SINK_FILE_SIZE_BYTES_MAX { + rotate_log_file( + file, + SINK_FILE_PATH.get(), + SINK_FILE_PATH_ROTATE.get(), + &SINK_FILE_SIZE_BYTES, + ); + } + } +} + +pub fn flush() { + _ = std::io::stdout().lock().flush(); +} + +struct ScopeFmt(Scope); + +impl std::fmt::Display for ScopeFmt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + f.write_str(self.0[0])?; + for scope in &self.0[1..] { + if !scope.is_empty() { + f.write_char(SCOPE_STRING_SEP_CHAR)?; + } + f.write_str(scope)?; + } + Ok(()) + } +} + +pub struct Record<'a> { + pub scope: Scope, + pub level: log::Level, + pub message: &'a std::fmt::Arguments<'a>, +} + +fn rotate_log_file( + file: &mut fs::File, + path: Option, + path_rotate: Option, + atomic_size: &AtomicU64, +) where + PathRef: AsRef, +{ + if let Err(err) = file.flush() { + eprintln!( + "Failed to flush log file before rotating, some logs may be lost: {}", + err + ); + } + let rotation_error = match (path, path_rotate) { + (Some(_), None) => Some(anyhow::anyhow!("No rotation log file path configured")), + (None, _) => Some(anyhow::anyhow!("No log file path configured")), + (Some(path), Some(path_rotate)) => fs::copy(path, path_rotate) + .err() + .map(|err| anyhow::anyhow!(err)), + }; + if let Some(err) = rotation_error { + eprintln!( + "Log file rotation failed. Truncating log file anyways: {}", + err, + ); + } + _ = file.set_len(0); + + // SAFETY: It is safe to set size to 0 even if set_len fails as + // according to the documentation, it only fails if: + // - the file is not writeable: should never happen, + // - the size would cause an overflow (implementation specific): 0 should never cause an overflow + atomic_size.store(0, Ordering::Relaxed); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rotate_log_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let log_file_path = temp_dir.path().join("log.txt"); + let rotation_log_file_path = temp_dir.path().join("log_rotated.txt"); + + let mut file = fs::File::create(&log_file_path).unwrap(); + let contents = String::from("Hello, world!"); + file.write_all(contents.as_bytes()).unwrap(); + + let size = AtomicU64::new(contents.len() as u64); + + rotate_log_file( + &mut file, + Some(&log_file_path), + Some(&rotation_log_file_path), + &size, + ); + + assert!(log_file_path.exists()); + assert_eq!(log_file_path.metadata().unwrap().len(), 0); + assert!(rotation_log_file_path.exists()); + assert_eq!( + std::fs::read_to_string(&rotation_log_file_path).unwrap(), + contents, + ); + assert_eq!(size.load(Ordering::Relaxed), 0); + } + + #[test] + fn test_log_level_names() { + assert_eq!(LEVEL_OUTPUT_STRINGS[log::Level::Error as usize], "ERROR"); + assert_eq!(LEVEL_OUTPUT_STRINGS[log::Level::Warn as usize], "WARN "); + assert_eq!(LEVEL_OUTPUT_STRINGS[log::Level::Info as usize], "INFO "); + assert_eq!(LEVEL_OUTPUT_STRINGS[log::Level::Debug as usize], "DEBUG"); + assert_eq!(LEVEL_OUTPUT_STRINGS[log::Level::Trace as usize], "TRACE"); + } +} diff --git a/crates/zlog/src/zlog.rs b/crates/zlog/src/zlog.rs index b953409223..9191335e41 100644 --- a/crates/zlog/src/zlog.rs +++ b/crates/zlog/src/zlog.rs @@ -2,18 +2,27 @@ pub use log as log_impl; mod env_config; +pub mod filter; +pub mod sink; + +pub use sink::{init_output_file, init_output_stdout}; pub const SCOPE_DEPTH_MAX: usize = 4; -pub fn init_from_env() { +pub fn init() { + process_env(); + log::set_logger(&ZLOG).expect("Logger should not be initialized twice"); + log::set_max_level(log::LevelFilter::max()); +} + +pub fn process_env() { let Ok(env_config) = std::env::var("ZED_LOG").or_else(|_| std::env::var("RUST_LOG")) else { return; }; match env_config::parse(&env_config) { Ok(filter) => { - scope_map::init_env_filter(filter); - scope_map::refresh(); - // TODO: set max level once removing `env_logger` and `simple_log` crates + filter::init_env_filter(filter); + filter::refresh(); } Err(err) => { eprintln!("Failed to parse log filter: {}", err); @@ -21,25 +30,43 @@ pub fn init_from_env() { } } -/// because we are currently just wrapping the `log` crate in `zlog`, -/// we need to work around the fact that the `log` crate only provides a -/// single global level filter. In order to have more precise control until -/// we no longer wrap `log`, we bump up the priority of log level so that it -/// will be logged, even if the actual level is lower -/// This is fine for now, as we use a `info` level filter by default in releases, -/// which hopefully won't result in confusion like `warn` or `error` levels might. -pub fn min_printed_log_level(level: log_impl::Level) -> log_impl::Level { - // this logic is defined based on the logic used in the `log` crate, - // which checks that a logs level is <= both of these values, - // so we take the minimum of the two values to ensure that check passes - let level_min_static = log_impl::STATIC_MAX_LEVEL; - let level_min_dynamic = log_impl::max_level(); - if level <= level_min_static && level <= level_min_dynamic { - return level; +static ZLOG: Zlog = Zlog {}; + +pub struct Zlog {} + +impl log::Log for Zlog { + fn enabled(&self, metadata: &log::Metadata) -> bool { + filter::is_possibly_enabled_level(metadata.level()) + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + let scope = match record.module_path_static() { + Some(module_path) => { + // TODO: better module name -> scope translation + let crate_name = private::extract_crate_name_from_module_path(module_path); + private::scope_new(&[crate_name]) + } + // TODO: when do we hit this + None => private::scope_new(&["*unknown*"]), + }; + let level = record.metadata().level(); + if !filter::is_scope_enabled(&scope, level) { + return; + } + sink::submit(sink::Record { + scope, + level, + message: record.args(), + }); + } + + fn flush(&self) { + // todo: necessary? + sink::flush(); } - return log_impl::LevelFilter::min(level_min_static, level_min_dynamic) - .to_level() - .unwrap_or(level); } #[macro_export] @@ -47,9 +74,13 @@ macro_rules! log { ($logger:expr, $level:expr, $($arg:tt)+) => { let level = $level; let logger = $logger; - let (enabled, level) = $crate::scope_map::is_scope_enabled(&logger.scope, level); + let enabled = $crate::filter::is_scope_enabled(&logger.scope, level); if enabled { - $crate::log_impl::log!(level, "[{}]: {}", &logger.fmt_scope(), format!($($arg)+)); + $crate::sink::submit($crate::sink::Record { + scope: logger.scope, + level, + message: &format_args!($($arg)+), + }); } } } @@ -205,27 +236,14 @@ pub mod private { pub type Scope = [&'static str; SCOPE_DEPTH_MAX]; pub type ScopeAlloc = [String; SCOPE_DEPTH_MAX]; -const SCOPE_STRING_SEP: &'static str = "."; +const SCOPE_STRING_SEP_STR: &'static str = "."; +const SCOPE_STRING_SEP_CHAR: char = '.'; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Logger { pub scope: Scope, } -impl Logger { - pub fn fmt_scope(&self) -> String { - let mut last = 0; - for s in self.scope { - if s.is_empty() { - break; - } - last += 1; - } - - return self.scope[0..last].join(SCOPE_STRING_SEP); - } -} - pub struct Timer { pub logger: Logger, pub start_time: std::time::Instant, @@ -288,543 +306,6 @@ impl Timer { } } -pub mod scope_map { - use std::{ - collections::{HashMap, VecDeque}, - hash::{DefaultHasher, Hasher}, - sync::{ - OnceLock, RwLock, - atomic::{AtomicU64, Ordering}, - }, - usize, - }; - - use super::*; - static ENV_FILTER: OnceLock = OnceLock::new(); - static SCOPE_MAP: RwLock> = RwLock::new(None); - static SCOPE_MAP_HASH: AtomicU64 = AtomicU64::new(0); - - pub fn init_env_filter(filter: env_config::EnvFilter) { - if ENV_FILTER.set(filter).is_err() { - panic!("Environment filter cannot be initialized twice"); - } - } - - pub fn is_scope_enabled(scope: &Scope, level: log_impl::Level) -> (bool, log_impl::Level) { - let level_min = min_printed_log_level(level); - if level <= level_min { - // [FAST PATH] - // if the message is at or below the minimum printed log level - // (where error < warn < info etc) then always enable - return (true, level); - } - - let Ok(map) = SCOPE_MAP.read() else { - // on failure, default to enabled detection done by `log` crate - return (true, level); - }; - - let Some(map) = map.as_ref() else { - // on failure, default to enabled detection done by `log` crate - return (true, level); - }; - - if map.is_empty() { - // if no scopes are enabled, default to enabled detection done by `log` crate - return (true, level); - } - let enabled_status = map.is_enabled(&scope, level); - match enabled_status { - EnabledStatus::NotConfigured => { - // if this scope isn't configured, default to enabled detection done by `log` crate - return (true, level); - } - EnabledStatus::Enabled => { - // if this scope is enabled, enable logging - // note: bumping level to min level that will be printed - // to work around log crate limitations - return (true, level_min); - } - EnabledStatus::Disabled => { - // if the configured level is lower than the requested level, disable logging - // note: err = 0, warn = 1, etc. - return (false, level); - } - } - } - - fn hash_scope_map_settings(map: &HashMap) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut items = map.iter().collect::>(); - items.sort(); - for (key, value) in items { - Hasher::write(&mut hasher, key.as_bytes()); - Hasher::write(&mut hasher, value.as_bytes()); - } - return hasher.finish(); - } - - pub(crate) fn refresh() { - refresh_from_settings(&HashMap::default()); - } - - pub fn refresh_from_settings(settings: &HashMap) { - let hash_old = SCOPE_MAP_HASH.load(Ordering::Acquire); - let hash_new = hash_scope_map_settings(settings); - if hash_old == hash_new && hash_old != 0 { - return; - } - let env_config = ENV_FILTER.get(); - let map_new = ScopeMap::new_from_settings_and_env(settings, env_config); - - if let Ok(_) = SCOPE_MAP_HASH.compare_exchange( - hash_old, - hash_new, - Ordering::Release, - Ordering::Relaxed, - ) { - let mut map = SCOPE_MAP.write().unwrap_or_else(|err| { - SCOPE_MAP.clear_poison(); - err.into_inner() - }); - *map = Some(map_new); - } - } - - fn level_from_level_str(level_str: &String) -> Option { - let level = match level_str.to_ascii_lowercase().as_str() { - "" => log_impl::Level::Trace, - "trace" => log_impl::Level::Trace, - "debug" => log_impl::Level::Debug, - "info" => log_impl::Level::Info, - "warn" => log_impl::Level::Warn, - "error" => log_impl::Level::Error, - "off" | "disable" | "no" | "none" | "disabled" => { - crate::warn!( - "Invalid log level \"{level_str}\", set to error to disable non-error logging. Defaulting to error" - ); - log_impl::Level::Error - } - _ => { - crate::warn!("Invalid log level \"{level_str}\", ignoring"); - return None; - } - }; - return Some(level); - } - - fn scope_alloc_from_scope_str(scope_str: &String) -> Option { - let mut scope_buf = [""; SCOPE_DEPTH_MAX]; - let mut index = 0; - let mut scope_iter = scope_str.split(SCOPE_STRING_SEP); - while index < SCOPE_DEPTH_MAX { - let Some(scope) = scope_iter.next() else { - break; - }; - if scope == "" { - continue; - } - scope_buf[index] = scope; - index += 1; - } - if index == 0 { - return None; - } - if let Some(_) = scope_iter.next() { - crate::warn!( - "Invalid scope key, too many nested scopes: '{scope_str}'. Max depth is {SCOPE_DEPTH_MAX}", - ); - return None; - } - let scope = scope_buf.map(|s| s.to_string()); - return Some(scope); - } - - pub struct ScopeMap { - entries: Vec, - root_count: usize, - } - - pub struct ScopeMapEntry { - scope: String, - enabled: Option, - descendants: std::ops::Range, - } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum EnabledStatus { - Enabled, - Disabled, - NotConfigured, - } - - impl ScopeMap { - pub fn new_from_settings_and_env( - items_input_map: &HashMap, - env_config: Option<&env_config::EnvFilter>, - ) -> Self { - let mut items = Vec::with_capacity( - items_input_map.len() + env_config.map_or(0, |c| c.directive_names.len()), - ); - if let Some(env_filter) = env_config { - // TODO: parse on load instead of every reload - items.extend( - env_filter - .directive_names - .iter() - .zip(env_filter.directive_levels.iter()) - .filter_map(|(scope, level_filter)| { - if items_input_map.get(scope).is_some() { - return None; - } - let scope = scope_alloc_from_scope_str(scope)?; - // TODO: use level filters instead of scopes in scope map - let level = level_filter.to_level()?; - - Some((scope, level)) - }), - ); - } - items.extend( - items_input_map - .into_iter() - .filter_map(|(scope_str, level_str)| { - let scope = scope_alloc_from_scope_str(&scope_str)?; - let level = level_from_level_str(&level_str)?; - return Some((scope, level)); - }), - ); - - items.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut this = Self { - entries: Vec::with_capacity(items.len() * SCOPE_DEPTH_MAX), - root_count: 0, - }; - - let items_count = items.len(); - - struct ProcessQueueEntry { - parent_index: usize, - depth: usize, - items_range: std::ops::Range, - } - let mut process_queue = VecDeque::new(); - process_queue.push_back(ProcessQueueEntry { - parent_index: usize::MAX, - depth: 0, - items_range: 0..items_count, - }); - - let empty_range = 0..0; - - while let Some(process_entry) = process_queue.pop_front() { - let ProcessQueueEntry { - items_range, - depth, - parent_index, - } = process_entry; - let mut cursor = items_range.start; - let res_entries_start = this.entries.len(); - while cursor < items_range.end { - let sub_items_start = cursor; - cursor += 1; - let scope_name = &items[sub_items_start].0[depth]; - while cursor < items_range.end && &items[cursor].0[depth] == scope_name { - cursor += 1; - } - let sub_items_end = cursor; - if scope_name == "" { - assert_eq!(sub_items_start + 1, sub_items_end); - assert_ne!(depth, 0); - assert_ne!(parent_index, usize::MAX); - assert!(this.entries[parent_index].enabled.is_none()); - this.entries[parent_index].enabled = Some(items[sub_items_start].1); - continue; - } - let is_valid_scope = scope_name != ""; - let is_last = depth + 1 == SCOPE_DEPTH_MAX || !is_valid_scope; - let mut enabled = None; - if is_last { - assert_eq!( - sub_items_start + 1, - sub_items_end, - "Expected one item: got: {:?}", - &items[items_range.clone()] - ); - enabled = Some(items[sub_items_start].1); - } else { - let entry_index = this.entries.len(); - process_queue.push_back(ProcessQueueEntry { - items_range: sub_items_start..sub_items_end, - parent_index: entry_index, - depth: depth + 1, - }); - } - this.entries.push(ScopeMapEntry { - scope: scope_name.to_owned(), - enabled, - descendants: empty_range.clone(), - }); - } - let res_entries_end = this.entries.len(); - if parent_index != usize::MAX { - this.entries[parent_index].descendants = res_entries_start..res_entries_end; - } else { - this.root_count = res_entries_end; - } - } - - return this; - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - pub fn is_enabled( - &self, - scope: &[S; SCOPE_DEPTH_MAX], - level: log_impl::Level, - ) -> EnabledStatus - where - S: AsRef, - { - let mut enabled = None; - let mut cur_range = &self.entries[0..self.root_count]; - let mut depth = 0; - - 'search: while !cur_range.is_empty() - && depth < SCOPE_DEPTH_MAX - && scope[depth].as_ref() != "" - { - for entry in cur_range { - if entry.scope == scope[depth].as_ref() { - // note: - enabled = entry.enabled.or(enabled); - cur_range = &self.entries[entry.descendants.clone()]; - depth += 1; - continue 'search; - } - } - break 'search; - } - - return enabled.map_or(EnabledStatus::NotConfigured, |level_enabled| { - if level <= level_enabled { - EnabledStatus::Enabled - } else { - EnabledStatus::Disabled - } - }); - } - } - - #[cfg(test)] - mod tests { - use crate::private::scope_new; - - use super::*; - - fn scope_map_from_keys(kv: &[(&str, &str)]) -> ScopeMap { - let hash_map: HashMap = kv - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - ScopeMap::new_from_settings_and_env(&hash_map, None) - } - - #[test] - fn test_initialization() { - let map = scope_map_from_keys(&[("a.b.c.d", "trace")]); - assert_eq!(map.root_count, 1); - assert_eq!(map.entries.len(), 4); - - let map = scope_map_from_keys(&[]); - assert_eq!(map.root_count, 0); - assert_eq!(map.entries.len(), 0); - - let map = scope_map_from_keys(&[("", "trace")]); - assert_eq!(map.root_count, 0); - assert_eq!(map.entries.len(), 0); - - let map = scope_map_from_keys(&[("foo..bar", "trace")]); - assert_eq!(map.root_count, 1); - assert_eq!(map.entries.len(), 2); - - let map = scope_map_from_keys(&[ - ("a.b.c.d", "trace"), - ("e.f.g.h", "debug"), - ("i.j.k.l", "info"), - ("m.n.o.p", "warn"), - ("q.r.s.t", "error"), - ]); - assert_eq!(map.root_count, 5); - assert_eq!(map.entries.len(), 20); - assert_eq!(map.entries[0].scope, "a"); - assert_eq!(map.entries[1].scope, "e"); - assert_eq!(map.entries[2].scope, "i"); - assert_eq!(map.entries[3].scope, "m"); - assert_eq!(map.entries[4].scope, "q"); - } - - fn scope_from_scope_str(scope_str: &'static str) -> Scope { - let mut scope_buf = [""; SCOPE_DEPTH_MAX]; - let mut index = 0; - let mut scope_iter = scope_str.split(SCOPE_STRING_SEP); - while index < SCOPE_DEPTH_MAX { - let Some(scope) = scope_iter.next() else { - break; - }; - if scope == "" { - continue; - } - scope_buf[index] = scope; - index += 1; - } - assert_ne!(index, 0); - assert!(scope_iter.next().is_none()); - return scope_buf; - } - - #[test] - fn test_is_enabled() { - let map = scope_map_from_keys(&[ - ("a.b.c.d", "trace"), - ("e.f.g.h", "debug"), - ("i.j.k.l", "info"), - ("m.n.o.p", "warn"), - ("q.r.s.t", "error"), - ]); - use log_impl::Level; - assert_eq!( - map.is_enabled(&scope_from_scope_str("a.b.c.d"), Level::Trace), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("a.b.c.d"), Level::Debug), - EnabledStatus::Enabled - ); - - assert_eq!( - map.is_enabled(&scope_from_scope_str("e.f.g.h"), Level::Debug), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("e.f.g.h"), Level::Info), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("e.f.g.h"), Level::Trace), - EnabledStatus::Disabled - ); - - assert_eq!( - map.is_enabled(&scope_from_scope_str("i.j.k.l"), Level::Info), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("i.j.k.l"), Level::Warn), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("i.j.k.l"), Level::Debug), - EnabledStatus::Disabled - ); - - assert_eq!( - map.is_enabled(&scope_from_scope_str("m.n.o.p"), Level::Warn), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("m.n.o.p"), Level::Error), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("m.n.o.p"), Level::Info), - EnabledStatus::Disabled - ); - - assert_eq!( - map.is_enabled(&scope_from_scope_str("q.r.s.t"), Level::Error), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_from_scope_str("q.r.s.t"), Level::Warn), - EnabledStatus::Disabled - ); - } - - fn scope_map_from_keys_and_env( - kv: &[(&str, &str)], - env: &env_config::EnvFilter, - ) -> ScopeMap { - let hash_map: HashMap = kv - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - ScopeMap::new_from_settings_and_env(&hash_map, Some(env)) - } - - #[test] - fn test_initialization_with_env() { - let env_filter = env_config::parse("a.b=debug,u=error").unwrap(); - let map = scope_map_from_keys_and_env(&[], &env_filter); - assert_eq!(map.root_count, 2); - assert_eq!(map.entries.len(), 3); - assert_eq!( - map.is_enabled(&scope_new(&["a"]), log_impl::Level::Debug), - EnabledStatus::NotConfigured - ); - assert_eq!( - map.is_enabled(&scope_new(&["a", "b"]), log_impl::Level::Debug), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_new(&["a", "b", "c"]), log_impl::Level::Trace), - EnabledStatus::Disabled - ); - - let env_filter = env_config::parse("a.b=debug,e.f.g.h=trace,u=error").unwrap(); - let map = scope_map_from_keys_and_env( - &[ - ("a.b.c.d", "trace"), - ("e.f.g.h", "debug"), - ("i.j.k.l", "info"), - ("m.n.o.p", "warn"), - ("q.r.s.t", "error"), - ], - &env_filter, - ); - assert_eq!(map.root_count, 6); - assert_eq!(map.entries.len(), 21); - assert_eq!(map.entries[0].scope, "a"); - assert_eq!(map.entries[1].scope, "e"); - assert_eq!(map.entries[2].scope, "i"); - assert_eq!(map.entries[3].scope, "m"); - assert_eq!(map.entries[4].scope, "q"); - assert_eq!(map.entries[5].scope, "u"); - assert_eq!( - map.is_enabled(&scope_new(&["a", "b", "c", "d"]), log_impl::Level::Trace), - EnabledStatus::Enabled - ); - assert_eq!( - map.is_enabled(&scope_new(&["a", "b", "c"]), log_impl::Level::Trace), - EnabledStatus::Disabled - ); - assert_eq!( - map.is_enabled(&scope_new(&["u", "v"]), log_impl::Level::Warn), - EnabledStatus::Disabled - ); - // settings override env - assert_eq!( - map.is_enabled(&scope_new(&["e", "f", "g", "h"]), log_impl::Level::Trace), - EnabledStatus::Disabled, - ); - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/zlog_settings/src/zlog_settings.rs b/crates/zlog_settings/src/zlog_settings.rs index 36e01dab24..fde28ba918 100644 --- a/crates/zlog_settings/src/zlog_settings.rs +++ b/crates/zlog_settings/src/zlog_settings.rs @@ -10,7 +10,7 @@ pub fn init(cx: &mut App) { cx.observe_global::(|cx| { let zlog_settings = ZlogSettings::get_global(cx); - zlog::scope_map::refresh_from_settings(&zlog_settings.scopes); + zlog::filter::refresh_from_settings(&zlog_settings.scopes); }) .detach(); } From b45230784d8ac660a5a71fe4152c77706f065d56 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 14 Apr 2025 08:39:33 -0600 Subject: [PATCH 39/75] agent: Handle context window exceeded errors from Anthropic (#28688) ![CleanShot 2025-04-14 at 11 15 38@2x](https://github.com/user-attachments/assets/9e803ffb-74fd-486b-bebc-2155a407a9fa) Release Notes: - agent: Handle context window exceeded errors from Anthropic --- Cargo.lock | 1 + crates/agent/src/message_editor.rs | 42 ++++++++++++--- crates/agent/src/thread.rs | 51 +++++++++++++++---- crates/agent/src/thread_store.rs | 7 ++- crates/anthropic/src/anthropic.rs | 50 ++++++++++++++++++ crates/language_model/src/language_model.rs | 8 ++- crates/language_models/Cargo.toml | 1 + .../language_models/src/provider/anthropic.rs | 24 +++++++-- crates/language_models/src/provider/cloud.rs | 34 ++++++++++--- 9 files changed, 190 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9e2fb8fdd..ef35a57f28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7708,6 +7708,7 @@ dependencies = [ "smol", "strum", "theme", + "thiserror 2.0.12", "tiktoken-rs", "tokio", "ui", diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 3b8881f2d8..573e4b4d03 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -761,13 +761,29 @@ impl MessageEditor { }) } - fn render_reaching_token_limit(&self, line_height: Pixels, cx: &mut Context) -> Div { + fn render_token_limit_callout( + &self, + line_height: Pixels, + token_usage_ratio: TokenUsageRatio, + cx: &mut Context, + ) -> Div { + let heading = if token_usage_ratio == TokenUsageRatio::Exceeded { + "Thread reached the token limit" + } else { + "Thread reaching the token limit soon" + }; + h_flex() .p_2() .gap_2() .flex_wrap() .justify_between() - .bg(cx.theme().status().warning_background.opacity(0.1)) + .bg( + if token_usage_ratio == TokenUsageRatio::Exceeded { + cx.theme().status().error_background.opacity(0.1) + } else { + cx.theme().status().warning_background.opacity(0.1) + }) .border_t_1() .border_color(cx.theme().colors().border) .child( @@ -779,15 +795,21 @@ impl MessageEditor { .h(line_height) .justify_center() .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall), + if token_usage_ratio == TokenUsageRatio::Exceeded { + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::XSmall) + } else { + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall) + } ), ) .child( v_flex() .mr_auto() - .child(Label::new("Thread reaching the token limit soon").size(LabelSize::Small)) + .child(Label::new(heading).size(LabelSize::Small)) .child( Label::new( "Start a new thread from a summary to continue the conversation.", @@ -875,7 +897,13 @@ impl Render for MessageEditor { .child(self.render_editor(font_size, line_height, window, cx)) .when( total_token_usage.ratio != TokenUsageRatio::Normal, - |parent| parent.child(self.render_reaching_token_limit(line_height, cx)), + |parent| { + parent.child(self.render_token_limit_callout( + line_height, + total_token_usage.ratio, + cx, + )) + }, ) } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index abd449a4f0..190f2ace0e 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -15,10 +15,11 @@ use futures::{FutureExt, StreamExt as _}; use git::repository::DiffType; use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, - LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, - LanguageModelToolResult, LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, - PaymentRequiredError, Role, StopReason, TokenUsage, + ConfiguredModel, LanguageModel, LanguageModelCompletionEvent, LanguageModelId, + LanguageModelKnownError, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, + LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, PaymentRequiredError, + Role, StopReason, TokenUsage, }; use project::Project; use project::git_store::{GitStore, GitStoreCheckpoint, RepositoryState}; @@ -228,7 +229,7 @@ pub struct TotalTokenUsage { pub ratio: TokenUsageRatio, } -#[derive(Default, PartialEq, Eq)] +#[derive(Debug, Default, PartialEq, Eq)] pub enum TokenUsageRatio { #[default] Normal, @@ -260,11 +261,20 @@ pub struct Thread { pending_checkpoint: Option, initial_project_snapshot: Shared>>>, cumulative_token_usage: TokenUsage, + exceeded_window_error: Option, feedback: Option, message_feedback: HashMap, last_auto_capture_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExceededWindowError { + /// Model used when last message exceeded context window + model_id: LanguageModelId, + /// Token count including last message + token_count: usize, +} + impl Thread { pub fn new( project: Entity, @@ -301,6 +311,7 @@ impl Thread { .shared() }, cumulative_token_usage: TokenUsage::default(), + exceeded_window_error: None, feedback: None, message_feedback: HashMap::default(), last_auto_capture_at: None, @@ -367,6 +378,7 @@ impl Thread { action_log: cx.new(|_| ActionLog::new(project)), initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(), cumulative_token_usage: serialized.cumulative_token_usage, + exceeded_window_error: None, feedback: None, message_feedback: HashMap::default(), last_auto_capture_at: None, @@ -817,6 +829,7 @@ impl Thread { initial_project_snapshot, cumulative_token_usage: this.cumulative_token_usage.clone(), detailed_summary_state: this.detailed_summary_state.clone(), + exceeded_window_error: this.exceeded_window_error.clone(), }) }) } @@ -1129,6 +1142,20 @@ impl Thread { cx.emit(ThreadEvent::ShowError( ThreadError::MaxMonthlySpendReached, )); + } else if let Some(known_error) = + error.downcast_ref::() + { + match known_error { + LanguageModelKnownError::ContextWindowLimitExceeded { + tokens, + } => { + thread.exceeded_window_error = Some(ExceededWindowError { + model_id: model.id(), + token_count: *tokens, + }); + cx.notify(); + } + } } else { let error_message = error .chain() @@ -1784,10 +1811,6 @@ impl Thread { &self.project } - pub fn cumulative_token_usage(&self) -> TokenUsage { - self.cumulative_token_usage.clone() - } - pub fn auto_capture_telemetry(&mut self, cx: &mut Context) { if !cx.has_flag::() { return; @@ -1840,6 +1863,16 @@ impl Thread { let max = model.model.max_token_count(); + if let Some(exceeded_error) = &self.exceeded_window_error { + if model.model.id() == exceeded_error.model_id { + return TotalTokenUsage { + total: exceeded_error.token_count, + max, + ratio: TokenUsageRatio::Exceeded, + }; + } + } + #[cfg(debug_assertions)] let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD") .unwrap_or("0.8".to_string()) diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index c8f8d239a2..ebb673a86f 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -27,7 +27,9 @@ use serde::{Deserialize, Serialize}; use settings::{Settings as _, SettingsStore}; use util::ResultExt as _; -use crate::thread::{DetailedSummaryState, MessageId, ProjectSnapshot, Thread, ThreadId}; +use crate::thread::{ + DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId, +}; const RULES_FILE_NAMES: [&'static str; 6] = [ ".rules", @@ -491,6 +493,8 @@ pub struct SerializedThread { pub cumulative_token_usage: TokenUsage, #[serde(default)] pub detailed_summary_state: DetailedSummaryState, + #[serde(default)] + pub exceeded_window_error: Option, } impl SerializedThread { @@ -577,6 +581,7 @@ impl LegacySerializedThread { initial_project_snapshot: self.initial_project_snapshot, cumulative_token_usage: TokenUsage::default(), detailed_summary_state: DetailedSummaryState::default(), + exceeded_window_error: None, } } } diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index e0c215bc3a..2e403fd0fa 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -724,4 +724,54 @@ impl ApiError { pub fn is_rate_limit_error(&self) -> bool { matches!(self.error_type.as_str(), "rate_limit_error") } + + pub fn match_window_exceeded(&self) -> Option { + let Some(ApiErrorCode::InvalidRequestError) = self.code() else { + return None; + }; + + parse_prompt_too_long(&self.message) + } +} + +pub fn parse_prompt_too_long(message: &str) -> Option { + message + .strip_prefix("prompt is too long: ")? + .split_once(" tokens")? + .0 + .parse::() + .ok() +} + +#[test] +fn test_match_window_exceeded() { + let error = ApiError { + error_type: "invalid_request_error".to_string(), + message: "prompt is too long: 220000 tokens > 200000".to_string(), + }; + assert_eq!(error.match_window_exceeded(), Some(220_000)); + + let error = ApiError { + error_type: "invalid_request_error".to_string(), + message: "prompt is too long: 1234953 tokens".to_string(), + }; + assert_eq!(error.match_window_exceeded(), Some(1234953)); + + let error = ApiError { + error_type: "invalid_request_error".to_string(), + message: "not a prompt length error".to_string(), + }; + assert_eq!(error.match_window_exceeded(), None); + + let error = ApiError { + error_type: "rate_limit_error".to_string(), + message: "prompt is too long: 12345 tokens".to_string(), + }; + assert_eq!(error.match_window_exceeded(), None); + + let error = ApiError { + error_type: "invalid_request_error".to_string(), + message: "prompt is too long: invalid tokens".to_string(), + }; + assert_eq!(error.match_window_exceeded(), None); } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 98456e7db4..e1ec23410e 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -278,6 +278,12 @@ pub trait LanguageModel: Send + Sync { } } +#[derive(Debug, Error)] +pub enum LanguageModelKnownError { + #[error("Context window limit exceeded ({tokens})")] + ContextWindowLimitExceeded { tokens: usize }, +} + pub trait LanguageModelTool: 'static + DeserializeOwned + JsonSchema { fn name() -> String; fn description() -> String; @@ -347,7 +353,7 @@ pub trait LanguageModelProviderState: 'static { } } -#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)] +#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd, Serialize, Deserialize)] pub struct LanguageModelId(pub SharedString); #[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)] diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index c1bea29691..6f2e11f493 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -47,6 +47,7 @@ settings.workspace = true smol.workspace = true strum.workspace = true theme.workspace = true +thiserror.workspace = true tiktoken-rs.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } ui.workspace = true diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 4540a08268..7746d214b4 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -13,8 +13,9 @@ use gpui::{ use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCacheConfiguration, LanguageModelId, - LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, MessageContent, RateLimiter, Role, + LanguageModelKnownError, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, MessageContent, + RateLimiter, Role, }; use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason}; use schemars::JsonSchema; @@ -454,7 +455,12 @@ impl LanguageModel for AnthropicModel { ); let request = self.stream_completion(request, cx); let future = self.request_limiter.stream(async move { - let response = request.await.map_err(|err| anyhow!(err))?; + let response = request + .await + .map_err(|err| match err.downcast::() { + Ok(anthropic_err) => anthropic_err_to_anyhow(anthropic_err), + Err(err) => anyhow!(err), + })?; Ok(map_to_language_model_completion_events(response)) }); async move { Ok(future.await?.boxed()) }.boxed() @@ -746,7 +752,7 @@ pub fn map_to_language_model_completion_events( _ => {} }, Err(err) => { - return Some((vec![Err(anyhow!(err))], state)); + return Some((vec![Err(anthropic_err_to_anyhow(err))], state)); } } } @@ -757,6 +763,16 @@ pub fn map_to_language_model_completion_events( .flat_map(futures::stream::iter) } +pub fn anthropic_err_to_anyhow(err: AnthropicError) -> anyhow::Error { + if let AnthropicError::ApiError(api_err) = &err { + if let Some(tokens) = api_err.match_window_exceeded() { + return anyhow!(LanguageModelKnownError::ContextWindowLimitExceeded { tokens }); + } + } + + anyhow!(err) +} + /// Updates usage data by preferring counts from `new`. fn update_usage(usage: &mut Usage, new: &Usage) { if let Some(input_tokens) = new.input_tokens { diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 6a08f48522..38d8c79d35 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,4 +1,4 @@ -use anthropic::{AnthropicError, AnthropicModelMode}; +use anthropic::{AnthropicError, AnthropicModelMode, parse_prompt_too_long}; use anyhow::{Result, anyhow}; use client::{ Client, EXPIRED_LLM_TOKEN_HEADER_NAME, MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME, @@ -14,7 +14,7 @@ use gpui::{AnyElement, AnyView, App, AsyncApp, Context, Entity, Subscription, Ta use http_client::{AsyncBody, HttpClient, Method, Response, StatusCode}; use language_model::{ AuthenticateError, CloudModel, LanguageModel, LanguageModelCacheConfiguration, LanguageModelId, - LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelKnownError, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelProviderTosView, LanguageModelRequest, LanguageModelToolSchemaFormat, RateLimiter, ZED_CLOUD_PROVIDER_ID, }; @@ -33,6 +33,7 @@ use std::{ time::Duration, }; use strum::IntoEnumIterator; +use thiserror::Error; use ui::{TintColor, prelude::*}; use crate::AllLanguageModelSettings; @@ -575,14 +576,19 @@ impl CloudLanguageModel { } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - return Err(anyhow!( - "cloud language model completion failed with status {status}: {body}", - )); + return Err(anyhow!(ApiError { status, body })); } } } } +#[derive(Debug, Error)] +#[error("cloud language model completion failed with status {status}: {body}")] +struct ApiError { + status: StatusCode, + body: String, +} + impl LanguageModel for CloudLanguageModel { fn id(&self) -> LanguageModelId { self.id.clone() @@ -696,7 +702,23 @@ impl LanguageModel for CloudLanguageModel { )?)?, }, ) - .await?; + .await + .map_err(|err| match err.downcast::() { + Ok(api_err) => { + if api_err.status == StatusCode::BAD_REQUEST { + if let Some(tokens) = parse_prompt_too_long(&api_err.body) { + return anyhow!( + LanguageModelKnownError::ContextWindowLimitExceeded { + tokens + } + ); + } + } + anyhow!(api_err) + } + Err(err) => anyhow!(err), + })?; + Ok( crate::provider::anthropic::map_to_language_model_completion_events( Box::pin(response_lines(response).map_err(AnthropicError::Other)), From fddaa3165580eec8ddf02a3847c4fa0d020c05fd Mon Sep 17 00:00:00 2001 From: duvetfall Date: Mon, 14 Apr 2025 19:01:47 +0400 Subject: [PATCH 40/75] assistant_tools: Fix code_action and rename schemas for Gemini (#28634) Closes #28475 Updates `rename` and `code_action` `input_schema` methods to use `json_schema_for()` which transforms standard JSONSchema into the subset required by Gemini. Also makes `input_schema` implementations consistent. Tested tools against Gemini 2.5 Pro Preview, Zed Claude 3.7 Sonnet Thinking, o3-mini Release Notes: - Agent Beta: Fixed error 400 `INVALID_ARGUMENT` when using Gemini with `code_actions` or `rename` tools enabled. --- crates/assistant_tools/src/code_action_tool.rs | 7 ++++--- crates/assistant_tools/src/rename_tool.rs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/assistant_tools/src/code_action_tool.rs b/crates/assistant_tools/src/code_action_tool.rs index 24253a61e5..7ef223b1b3 100644 --- a/crates/assistant_tools/src/code_action_tool.rs +++ b/crates/assistant_tools/src/code_action_tool.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; use std::{ops::Range, sync::Arc}; use ui::IconName; +use crate::schema::json_schema_for; + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CodeActionToolInput { /// The relative path to the file containing the text range. @@ -97,10 +99,9 @@ impl Tool for CodeActionTool { fn input_schema( &self, - _format: language_model::LanguageModelToolSchemaFormat, + format: language_model::LanguageModelToolSchemaFormat, ) -> serde_json::Value { - let schema = schemars::schema_for!(CodeActionToolInput); - serde_json::to_value(&schema).unwrap() + json_schema_for::(format) } fn ui_text(&self, input: &serde_json::Value) -> String { diff --git a/crates/assistant_tools/src/rename_tool.rs b/crates/assistant_tools/src/rename_tool.rs index ecc748407e..51e087f1b5 100644 --- a/crates/assistant_tools/src/rename_tool.rs +++ b/crates/assistant_tools/src/rename_tool.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use ui::IconName; +use crate::schema::json_schema_for; + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct RenameToolInput { /// The relative path to the file containing the symbol to rename. @@ -68,10 +70,9 @@ impl Tool for RenameTool { fn input_schema( &self, - _format: language_model::LanguageModelToolSchemaFormat, + format: language_model::LanguageModelToolSchemaFormat, ) -> serde_json::Value { - let schema = schemars::schema_for!(RenameToolInput); - serde_json::to_value(&schema).unwrap() + json_schema_for::(format) } fn ui_text(&self, input: &serde_json::Value) -> String { From 9863b48dd72695bde97a6a53b66bfa447f3634ac Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:06:41 +0200 Subject: [PATCH 41/75] project/perf: Optimize BufferStore::get_by_path with an additional index (#28670) Closes #27270 Release Notes: - Improved performance of git panel with large # of untracked files --- crates/project/src/buffer_store.rs | 139 ++++++++++++++--------------- 1 file changed, 68 insertions(+), 71 deletions(-) diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index c31f1bfbe5..815ba19ea9 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -36,6 +36,7 @@ pub struct BufferStore { loading_buffers: HashMap, Arc>>>>, worktree_store: Entity, opened_buffers: HashMap, + path_to_buffer_id: HashMap, downstream_client: Option<(AnyProtoClient, u64)>, shared_buffers: HashMap>, } @@ -62,7 +63,6 @@ struct RemoteBufferStore { } struct LocalBufferStore { - local_buffer_ids_by_path: HashMap, local_buffer_ids_by_entry_id: HashMap, worktree_store: Entity, _subscription: Subscription, @@ -368,8 +368,9 @@ impl LocalBufferStore { let line_ending = buffer.line_ending(); let version = buffer.version(); let buffer_id = buffer.remote_id(); - if buffer - .file() + let file = buffer.file().cloned(); + if file + .as_ref() .is_some_and(|file| file.disk_state() == DiskState::New) { has_changed_file = true; @@ -462,13 +463,11 @@ impl LocalBufferStore { path: path.clone(), }; - let buffer_id = { - let local = this.as_local_mut()?; - match local.local_buffer_ids_by_entry_id.get(&entry_id) { - Some(&buffer_id) => buffer_id, - None => local.local_buffer_ids_by_path.get(&project_path).copied()?, - } - }; + let buffer_id = this + .as_local_mut() + .and_then(|local| local.local_buffer_ids_by_entry_id.get(&entry_id)) + .copied() + .or_else(|| this.path_to_buffer_id.get(&project_path).copied())?; let buffer = if let Some(buffer) = this.get(buffer_id) { Some(buffer) @@ -480,14 +479,13 @@ impl LocalBufferStore { let buffer = if let Some(buffer) = buffer { buffer } else { + this.path_to_buffer_id.remove(&project_path); let this = this.as_local_mut()?; - this.local_buffer_ids_by_path.remove(&project_path); this.local_buffer_ids_by_entry_id.remove(&entry_id); return None; }; let events = buffer.update(cx, |buffer, cx| { - let local = this.as_local_mut()?; let file = buffer.file()?; let old_file = File::from_dyn(Some(file))?; if old_file.worktree != *worktree { @@ -528,11 +526,11 @@ impl LocalBufferStore { let mut events = Vec::new(); if new_file.path != old_file.path { - local.local_buffer_ids_by_path.remove(&ProjectPath { + this.path_to_buffer_id.remove(&ProjectPath { path: old_file.path.clone(), worktree_id: old_file.worktree_id(cx), }); - local.local_buffer_ids_by_path.insert( + this.path_to_buffer_id.insert( ProjectPath { worktree_id: new_file.worktree_id(cx), path: new_file.path.clone(), @@ -544,7 +542,7 @@ impl LocalBufferStore { old_file: buffer.file().cloned(), }); } - + let local = this.as_local_mut()?; if new_file.entry_id != old_file.entry_id { if let Some(entry_id) = old_file.entry_id { local.local_buffer_ids_by_entry_id.remove(&entry_id); @@ -577,32 +575,6 @@ impl LocalBufferStore { None } - fn buffer_changed_file(&mut self, buffer: Entity, cx: &mut App) -> Option<()> { - let file = File::from_dyn(buffer.read(cx).file())?; - - let remote_id = buffer.read(cx).remote_id(); - if let Some(entry_id) = file.entry_id { - match self.local_buffer_ids_by_entry_id.get(&entry_id) { - Some(_) => { - return None; - } - None => { - self.local_buffer_ids_by_entry_id - .insert(entry_id, remote_id); - } - } - }; - self.local_buffer_ids_by_path.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - remote_id, - ); - - Some(()) - } - fn save_buffer( &self, buffer: Entity, @@ -677,15 +649,14 @@ impl LocalBufferStore { this.add_buffer(buffer.clone(), cx)?; let buffer_id = buffer.read(cx).remote_id(); if let Some(file) = File::from_dyn(buffer.read(cx).file()) { - let this = this.as_local_mut().unwrap(); - this.local_buffer_ids_by_path.insert( + this.path_to_buffer_id.insert( ProjectPath { worktree_id: file.worktree_id(cx), path: file.path.clone(), }, buffer_id, ); - + let this = this.as_local_mut().unwrap(); if let Some(entry_id) = file.entry_id { this.local_buffer_ids_by_entry_id .insert(entry_id, buffer_id); @@ -748,7 +719,6 @@ impl BufferStore { pub fn local(worktree_store: Entity, cx: &mut Context) -> Self { Self { state: BufferStoreState::Local(LocalBufferStore { - local_buffer_ids_by_path: Default::default(), local_buffer_ids_by_entry_id: Default::default(), worktree_store: worktree_store.clone(), _subscription: cx.subscribe(&worktree_store, |this, _, event, cx| { @@ -760,6 +730,7 @@ impl BufferStore { }), downstream_client: None, opened_buffers: Default::default(), + path_to_buffer_id: Default::default(), shared_buffers: Default::default(), loading_buffers: Default::default(), worktree_store, @@ -783,19 +754,13 @@ impl BufferStore { }), downstream_client: None, opened_buffers: Default::default(), + path_to_buffer_id: Default::default(), loading_buffers: Default::default(), shared_buffers: Default::default(), worktree_store, } } - fn as_local(&self) -> Option<&LocalBufferStore> { - match &self.state { - BufferStoreState::Local(state) => Some(state), - _ => None, - } - } - fn as_local_mut(&mut self) -> Option<&mut LocalBufferStore> { match &mut self.state { BufferStoreState::Local(state) => Some(state), @@ -915,6 +880,10 @@ impl BufferStore { fn add_buffer(&mut self, buffer_entity: Entity, cx: &mut Context) -> Result<()> { let buffer = buffer_entity.read(cx); let remote_id = buffer.remote_id(); + let path = File::from_dyn(buffer.file()).map(|file| ProjectPath { + path: file.path.clone(), + worktree_id: file.worktree_id(cx), + }); let is_remote = buffer.replica_id() != 0; let open_buffer = OpenBuffer::Complete { buffer: buffer_entity.downgrade(), @@ -931,10 +900,11 @@ impl BufferStore { }) .detach() }); - + let _expect_path_to_exist; match self.opened_buffers.entry(remote_id) { hash_map::Entry::Vacant(entry) => { entry.insert(open_buffer); + _expect_path_to_exist = false; } hash_map::Entry::Occupied(mut entry) => { if let OpenBuffer::Operations(operations) = entry.get_mut() { @@ -948,9 +918,14 @@ impl BufferStore { } } entry.insert(open_buffer); + _expect_path_to_exist = true; } } + if let Some(path) = path { + self.path_to_buffer_id.insert(path, remote_id); + } + cx.subscribe(&buffer_entity, Self::on_buffer_event).detach(); cx.emit(BufferStoreEvent::BufferAdded(buffer_entity)); Ok(()) @@ -972,18 +947,13 @@ impl BufferStore { } pub fn buffer_id_for_project_path(&self, project_path: &ProjectPath) -> Option<&BufferId> { - self.as_local() - .and_then(|state| state.local_buffer_ids_by_path.get(project_path)) + self.path_to_buffer_id.get(project_path) } - pub fn get_by_path(&self, path: &ProjectPath, cx: &App) -> Option> { - self.buffers().find_map(|buffer| { - let file = File::from_dyn(buffer.read(cx).file())?; - if file.worktree_id(cx) == path.worktree_id && file.path == path.path { - Some(buffer) - } else { - None - } + pub fn get_by_path(&self, path: &ProjectPath, _cx: &App) -> Option> { + self.path_to_buffer_id.get(path).and_then(|buffer_id| { + let buffer = self.get(*buffer_id); + buffer }) } @@ -1055,6 +1025,35 @@ impl BufferStore { .retain(|_, buffer| !matches!(buffer, OpenBuffer::Operations(_))); } + fn buffer_changed_file(&mut self, buffer: Entity, cx: &mut App) -> Option<()> { + let file = File::from_dyn(buffer.read(cx).file())?; + + let remote_id = buffer.read(cx).remote_id(); + if let Some(entry_id) = file.entry_id { + if let Some(local) = self.as_local_mut() { + match local.local_buffer_ids_by_entry_id.get(&entry_id) { + Some(_) => { + return None; + } + None => { + local + .local_buffer_ids_by_entry_id + .insert(entry_id, remote_id); + } + } + } + self.path_to_buffer_id.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + remote_id, + ); + }; + + Some(()) + } + pub fn find_search_candidates( &mut self, query: &SearchQuery, @@ -1118,9 +1117,7 @@ impl BufferStore { ) { match event { BufferEvent::FileHandleChanged => { - if let Some(local) = self.as_local_mut() { - local.buffer_changed_file(buffer, cx); - } + self.buffer_changed_file(buffer, cx); } BufferEvent::Reloaded => { let Some((downstream_client, project_id)) = self.downstream_client.as_ref() else { @@ -1316,6 +1313,7 @@ impl BufferStore { let old_file = buffer.update(cx, |buffer, cx| { let old_file = buffer.file().cloned(); let new_path = file.path.clone(); + buffer.file_updated(Arc::new(file), cx); if old_file .as_ref() @@ -1606,18 +1604,17 @@ impl BufferStore { self.add_buffer(buffer.clone(), cx).log_err(); let buffer_id = buffer.read(cx).remote_id(); - let this = self - .as_local_mut() - .expect("local-only method called in a non-local context"); if let Some(file) = File::from_dyn(buffer.read(cx).file()) { - this.local_buffer_ids_by_path.insert( + self.path_to_buffer_id.insert( ProjectPath { worktree_id: file.worktree_id(cx), path: file.path.clone(), }, buffer_id, ); - + let this = self + .as_local_mut() + .expect("local-only method called in a non-local context"); if let Some(entry_id) = file.entry_id { this.local_buffer_ids_by_entry_id .insert(entry_id, buffer_id); From ac8a4ba5d4172526e1aef18b39d4f88ac1e26cb1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 14 Apr 2025 12:36:58 -0300 Subject: [PATCH 42/75] agent: Add scrollbar to the history view (#28690) Ended up not making this one visible only upon hover or something because the layout alignment would be weird given the list item spans the full width. So, experimenting with this design here: Release Notes: - agent: Add scrollbar to the history view. --- crates/agent/src/thread_history.rs | 174 ++++++++++++++++++++--------- 1 file changed, 119 insertions(+), 55 deletions(-) diff --git a/crates/agent/src/thread_history.rs b/crates/agent/src/thread_history.rs index bb0d8ca3fd..ecf5e958a7 100644 --- a/crates/agent/src/thread_history.rs +++ b/crates/agent/src/thread_history.rs @@ -4,11 +4,14 @@ use assistant_context_editor::SavedContextMetadata; use editor::{Editor, EditorEvent}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - App, Entity, FocusHandle, Focusable, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, - Window, uniform_list, + App, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, + WeakEntity, Window, uniform_list, }; use time::{OffsetDateTime, UtcOffset}; -use ui::{HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, + Tooltip, prelude::*, +}; use util::ResultExt; use crate::history_store::{HistoryEntry, HistoryStore}; @@ -26,6 +29,8 @@ pub struct ThreadHistory { matches: Vec, _subscriptions: Vec, _search_task: Option>, + scrollbar_visibility: bool, + scrollbar_state: ScrollbarState, } impl ThreadHistory { @@ -58,10 +63,13 @@ impl ThreadHistory { this.update_all_entries(cx); }); + let scroll_handle = UniformListScrollHandle::default(); + let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); + Self { assistant_panel, history_store, - scroll_handle: UniformListScrollHandle::default(), + scroll_handle, selected_index: 0, search_query: SharedString::new_static(""), all_entries: entries, @@ -69,6 +77,8 @@ impl ThreadHistory { search_editor, _subscriptions: vec![search_editor_subscription, history_store_subscription], _search_task: None, + scrollbar_visibility: true, + scrollbar_state, } } @@ -220,6 +230,43 @@ impl ThreadHistory { cx.notify(); } + fn render_scrollbar(&self, cx: &mut Context) -> Option> { + if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) { + return None; + } + + Some( + div() + .occlude() + .id("thread-history-scroll") + .h_full() + .bg(cx.theme().colors().panel_background.opacity(0.8)) + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .absolute() + .right_1() + .top_0() + .bottom_0() + .w_4() + .pl_1() + .cursor_default() + .on_mouse_move(cx.listener(|_, _, _window, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, _window, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, _window, cx| { + cx.stop_propagation(); + }) + .on_scroll_wheel(cx.listener(|_, _, _window, cx| { + cx.notify(); + })) + .children(Scrollbar::vertical(self.scrollbar_state.clone())), + ) + } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { @@ -305,7 +352,11 @@ impl Render for ThreadHistory { ) }) .child({ - let view = v_flex().overflow_hidden().flex_grow(); + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); if self.all_entries.is_empty() { view.justify_center() @@ -322,59 +373,70 @@ impl Render for ThreadHistory { ), ) } else { - view.p_1().child( - uniform_list( - cx.entity().clone(), - "thread-history", - self.matched_count(), - move |history, range, _window, _cx| { - let range_start = range.start; - let assistant_panel = history.assistant_panel.clone(); + view.pr_5() + .child( + uniform_list( + cx.entity().clone(), + "thread-history", + self.matched_count(), + move |history, range, _window, _cx| { + let range_start = range.start; + let assistant_panel = history.assistant_panel.clone(); - let render_item = |index: usize, - entry: &HistoryEntry, - highlight_positions: Vec| - -> Div { - h_flex().w_full().pb_1().child(match entry { - HistoryEntry::Thread(thread) => PastThread::new( - thread.clone(), - assistant_panel.clone(), - selected_index == index + range_start, - highlight_positions, - ) - .into_any_element(), - HistoryEntry::Context(context) => PastContext::new( - context.clone(), - assistant_panel.clone(), - selected_index == index + range_start, - highlight_positions, - ) - .into_any_element(), - }) - }; - - if history.has_search_query() { - history.matches[range] - .iter() - .enumerate() - .filter_map(|(index, m)| { - history.all_entries.get(m.candidate_id).map(|entry| { - render_item(index, entry, m.positions.clone()) - }) + let render_item = |index: usize, + entry: &HistoryEntry, + highlight_positions: Vec| + -> Div { + h_flex().w_full().pb_1().child(match entry { + HistoryEntry::Thread(thread) => PastThread::new( + thread.clone(), + assistant_panel.clone(), + selected_index == index + range_start, + highlight_positions, + ) + .into_any_element(), + HistoryEntry::Context(context) => PastContext::new( + context.clone(), + assistant_panel.clone(), + selected_index == index + range_start, + highlight_positions, + ) + .into_any_element(), }) - .collect() - } else { - history.all_entries[range] - .iter() - .enumerate() - .map(|(index, entry)| render_item(index, entry, vec![])) - .collect() - } - }, + }; + + if history.has_search_query() { + history.matches[range] + .iter() + .enumerate() + .filter_map(|(index, m)| { + history.all_entries.get(m.candidate_id).map( + |entry| { + render_item( + index, + entry, + m.positions.clone(), + ) + }, + ) + }) + .collect() + } else { + history.all_entries[range] + .iter() + .enumerate() + .map(|(index, entry)| render_item(index, entry, vec![])) + .collect() + } + }, + ) + .p_1() + .track_scroll(self.scroll_handle.clone()) + .flex_grow(), ) - .track_scroll(self.scroll_handle.clone()) - .flex_grow(), - ) + .when_some(self.render_scrollbar(cx), |div, scrollbar| { + div.child(scrollbar) + }) } }) } @@ -440,6 +502,7 @@ impl RenderOnce for PastThread { IconButton::new("delete", IconName::TrashAlt) .shape(IconButtonShape::Square) .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) .tooltip(move |window, cx| { Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) }) @@ -531,6 +594,7 @@ impl RenderOnce for PastContext { IconButton::new("delete", IconName::TrashAlt) .shape(IconButtonShape::Square) .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) .tooltip(move |window, cx| { Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) }) From 78ecc3cef08fa5206903af5565cd19a5d4b3e1bf Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 14 Apr 2025 21:07:19 +0530 Subject: [PATCH 43/75] git: Amend (#28187) Adds git amend support. - [x] Turn existing commit button into split button - [x] Clean up + Handle shortcuts/focus cases - [x] Test remote Release Notes: - Added git amend support. --------- Co-authored-by: Cole Miller --- assets/keymaps/default-linux.json | 11 + assets/keymaps/default-macos.json | 11 + crates/fs/src/fake_git_repo.rs | 5 +- crates/git/src/git.rs | 2 + crates/git/src/repository.rs | 17 +- crates/git_ui/src/commit_modal.rs | 278 +++++++++++++-- crates/git_ui/src/git_panel.rs | 321 +++++++++++++++--- crates/git_ui/src/git_ui.rs | 1 + crates/project/src/git_store.rs | 22 +- crates/proto/proto/git.proto | 5 + .../ui/src/components/button/split_button.rs | 6 + 11 files changed, 595 insertions(+), 84 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9c4a4d1f50..9b94ed32a1 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -782,6 +782,7 @@ "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], @@ -790,12 +791,20 @@ "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] } }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, { "context": "GitCommit > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "alt-l": "git::GenerateCommitMessage" } }, @@ -817,6 +826,7 @@ "context": "GitDiff > Editor", "bindings": { "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll" } @@ -835,6 +845,7 @@ "shift-tab": "git_panel::FocusChanges", "enter": "editor::Newline", "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", "alt-l": "git::GenerateCommitMessage" } diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ee24d9deb7..c5cf9e019b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -855,17 +855,26 @@ "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] } }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, { "context": "GitDiff > Editor", "use_key_equivalents": true, "bindings": { "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll" } @@ -876,6 +885,7 @@ "bindings": { "enter": "editor::Newline", "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "tab": "git_panel::FocusChanges", "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", @@ -905,6 +915,7 @@ "enter": "editor::Newline", "escape": "menu::Cancel", "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "alt-tab": "git::GenerateCommitMessage" } }, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 15750bfccc..1a2d28e241 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -5,8 +5,8 @@ use futures::future::{self, BoxFuture}; use git::{ blame::Blame, repository::{ - AskPassDelegate, Branch, CommitDetails, GitRepository, GitRepositoryCheckpoint, - PushOptions, Remote, RepoPath, ResetMode, + AskPassDelegate, Branch, CommitDetails, CommitOptions, GitRepository, + GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, }, status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; @@ -365,6 +365,7 @@ impl GitRepository for FakeGitRepository { &self, _message: gpui::SharedString, _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>, + _options: CommitOptions, _env: Arc>, ) -> BoxFuture> { unimplemented!() diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 615d807c38..668d5f9ac7 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -50,6 +50,8 @@ actions!( Pull, Fetch, Commit, + Amend, + Cancel, ExpandCommitEditor, GenerateCommitMessage, Init, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 060c14ffcc..28f0d1c910 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -74,6 +74,11 @@ impl Upstream { } } +#[derive(Clone, Copy, Default)] +pub struct CommitOptions { + pub amend: bool, +} + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum UpstreamTracking { /// Remote ref not present in local repository. @@ -252,6 +257,7 @@ pub trait GitRepository: Send + Sync { &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, + options: CommitOptions, env: Arc>, ) -> BoxFuture>; @@ -368,8 +374,8 @@ impl RealGitRepository { #[derive(Clone, Debug)] pub struct GitRepositoryCheckpoint { - ref_name: String, - commit_sha: Oid, + pub ref_name: String, + pub commit_sha: Oid, } impl GitRepository for RealGitRepository { @@ -957,6 +963,7 @@ impl GitRepository for RealGitRepository { &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, + options: CommitOptions, env: Arc>, ) -> BoxFuture> { let working_directory = self.working_directory(); @@ -969,6 +976,10 @@ impl GitRepository for RealGitRepository { .arg(&message.to_string()) .arg("--cleanup=strip"); + if options.amend { + cmd.arg("--amend"); + } + if let Some((name, email)) = name_and_email { cmd.arg("--author").arg(&format!("{name} <{email}>")); } @@ -1765,6 +1776,7 @@ mod tests { repo.commit( "Initial commit".into(), None, + CommitOptions::default(), Arc::new(checkpoint_author_envs()), ) .await @@ -1793,6 +1805,7 @@ mod tests { repo.commit( "Commit after checkpoint".into(), None, + CommitOptions::default(), Arc::new(checkpoint_author_envs()), ) .await diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 16b8525f75..c90e0deade 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -1,8 +1,12 @@ use crate::branch_picker::{self, BranchList}; use crate::git_panel::{GitPanel, commit_message_editor}; -use git::{Commit, GenerateCommitMessage}; +use git::repository::CommitOptions; +use git::{Amend, Commit, GenerateCommitMessage}; +use language::Buffer; use panel::{panel_button, panel_editor_style, panel_filled_button}; -use ui::{KeybindingHint, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{ + ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*, +}; use editor::{Editor, EditorElement}; use gpui::*; @@ -100,6 +104,9 @@ impl CommitModal { workspace.register_action(|workspace, _: &Commit, window, cx| { CommitModal::toggle(workspace, window, cx); }); + workspace.register_action(|workspace, _: &Amend, window, cx| { + CommitModal::toggle(workspace, window, cx); + }); } pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { @@ -214,23 +221,67 @@ impl CommitModal { ) } + fn render_git_commit_menu( + &self, + id: impl Into, + keybinding_target: Option, + ) -> impl IntoElement { + PopoverMenu::new(id.into()) + .trigger( + ui::ButtonLike::new_rounded_right("commit-split-button-right") + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + ), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .when_some(keybinding_target.clone(), |el, keybinding_target| { + el.context(keybinding_target.clone()) + }) + .action("Amend...", Amend.boxed_clone()) + })) + }) + .anchor(Corner::TopRight) + } + pub fn render_footer(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (can_commit, tooltip, commit_label, co_authors, generate_commit_message, active_repo) = - self.git_panel.update(cx, |git_panel, cx| { - let (can_commit, tooltip) = git_panel.configure_commit_button(cx); - let title = git_panel.commit_button_title(); - let co_authors = git_panel.render_co_authors(cx); - let generate_commit_message = git_panel.render_generate_commit_message_button(cx); - let active_repo = git_panel.active_repository.clone(); - ( - can_commit, - tooltip, - title, - co_authors, - generate_commit_message, - active_repo, - ) - }); + let ( + can_commit, + tooltip, + commit_label, + co_authors, + generate_commit_message, + active_repo, + is_amend_pending, + has_previous_commit, + ) = self.git_panel.update(cx, |git_panel, cx| { + let (can_commit, tooltip) = git_panel.configure_commit_button(cx); + let title = git_panel.commit_button_title(); + let co_authors = git_panel.render_co_authors(cx); + let generate_commit_message = git_panel.render_generate_commit_message_button(cx); + let active_repo = git_panel.active_repository.clone(); + let is_amend_pending = git_panel.amend_pending(); + let has_previous_commit = active_repo + .as_ref() + .and_then(|repo| repo.read(cx).branch.as_ref()) + .and_then(|branch| branch.most_recent_commit.as_ref()) + .is_some(); + ( + can_commit, + tooltip, + title, + co_authors, + generate_commit_message, + active_repo, + is_amend_pending, + has_previous_commit, + ) + }); let branch = active_repo .as_ref() @@ -277,21 +328,6 @@ impl CommitModal { None }; - let commit_button = panel_filled_button(commit_label) - .tooltip({ - let panel_editor_focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx) - } - }) - .disabled(!can_commit) - .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!("Git Committed", source = "Git Modal"); - this.git_panel - .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx)); - cx.emit(DismissEvent); - })); - h_flex() .group("commit_editor_footer") .flex_none() @@ -324,21 +360,188 @@ impl CommitModal { .px_1() .gap_4() .children(close_kb_hint) - .child(commit_button), + .when(is_amend_pending, |this| { + let focus_handle = focus_handle.clone(); + this.child( + panel_filled_button(commit_label) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::for_action_in( + tooltip, + &Amend, + &focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + }) + .disabled(!can_commit) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(git::Commit), cx); + }), + ) + }) + .when(!is_amend_pending, |this| { + this.when(has_previous_commit, |this| { + this.child(SplitButton::new( + ui::ButtonLike::new_rounded_left(ElementId::Name( + format!("split-button-left-{}", commit_label).into(), + )) + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::Compact) + .child( + div() + .child(Label::new(commit_label).size(LabelSize::Small)) + .mr_0p5(), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(git::Commit), cx); + }) + .disabled(!can_commit) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &focus_handle.clone(), + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }), + self.render_git_commit_menu( + ElementId::Name( + format!("split-button-right-{}", commit_label).into(), + ), + Some(focus_handle.clone()), + ) + .into_any_element(), + )) + }) + .when(!has_previous_commit, |this| { + this.child( + panel_filled_button(commit_label) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + }) + .disabled(!can_commit) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(git::Commit), cx); + }), + ) + }) + }), ) } fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); + if self.git_panel.read(cx).amend_pending() { + self.git_panel + .update(cx, |git_panel, _| git_panel.set_amend_pending(false)); + cx.notify(); + } else { + cx.emit(DismissEvent); + } + } + + pub fn commit_message_buffer(&self, cx: &App) -> Entity { + self.commit_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .clone() } fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.git_panel.read(cx).amend_pending() { + return; + } telemetry::event!("Git Committed", source = "Git Modal"); - self.git_panel - .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx)); + self.git_panel.update(cx, |git_panel, cx| { + git_panel.commit_changes(CommitOptions { amend: false }, window, cx) + }); cx.emit(DismissEvent); } + fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.git_panel.read(cx).active_repository.as_ref() else { + return; + }; + let Some(branch) = active_repository.read(cx).branch.as_ref() else { + return; + }; + let Some(recent_sha) = branch + .most_recent_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + return; + }; + if self + .commit_editor + .focus_handle(cx) + .contains_focused(window, cx) + { + if !self.git_panel.read(cx).amend_pending() { + self.git_panel.update(cx, |git_panel, _| { + git_panel.set_amend_pending(true); + }); + cx.notify(); + if self.commit_editor.read(cx).is_empty(cx) { + let detail_task = self.git_panel.update(cx, |git_panel, cx| { + git_panel.load_commit_details(recent_sha, cx) + }); + cx.spawn(async move |this, cx| { + if let Ok(message) = detail_task.await.map(|detail| detail.message) { + this.update(cx, |this, cx| { + this.commit_message_buffer(cx).update(cx, |buffer, cx| { + let insert_position = buffer.anchor_before(buffer.len()); + buffer.edit( + [(insert_position..insert_position, message)], + None, + cx, + ); + }); + }) + .log_err(); + } + }) + .detach(); + } + } else { + telemetry::event!("Git Amended", source = "Git Panel"); + self.git_panel.update(cx, |git_panel, cx| { + git_panel.set_amend_pending(false); + git_panel.commit_changes(CommitOptions { amend: true }, window, cx); + }); + cx.emit(DismissEvent); + } + } else { + cx.propagate(); + } + } + fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context) { if self.branch_list_handle.is_focused(window, cx) { self.focus_handle(cx).focus(window) @@ -361,6 +564,7 @@ impl Render for CommitModal { .key_context("GitCommit") .on_action(cx.listener(Self::dismiss)) .on_action(cx.listener(Self::commit)) + .on_action(cx.listener(Self::amend)) .on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| { this.git_panel.update(cx, |panel, cx| { panel.generate_commit_message(cx); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 3b0acc4161..6a5c0fb372 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -21,11 +21,11 @@ use editor::{ use futures::StreamExt as _; use git::blame::ParsedCommitMessage; use git::repository::{ - Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput, - ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, + Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, PushOptions, Remote, + RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, }; use git::status::StageStatus; -use git::{Commit, ToggleStaged, repository::RepoPath, status::FileStatus}; +use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; use gpui::{ Action, Animation, AnimationExt as _, Axis, ClickEvent, Corner, DismissEvent, Entity, @@ -59,8 +59,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, Tooltip, - prelude::*, + Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, SplitButton, + Tooltip, prelude::*, }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::AppState; @@ -340,6 +340,7 @@ pub struct GitPanel { new_staged_count: usize, pending: Vec, pending_commit: Option>, + amend_pending: bool, pending_serialization: Task>, pub(crate) project: Entity, scroll_handle: UniformListScrollHandle, @@ -492,6 +493,7 @@ impl GitPanel { new_staged_count: 0, pending: Vec::new(), pending_commit: None, + amend_pending: false, pending_serialization: Task::ready(None), single_staged_entry: None, single_tracked_entry: None, @@ -1417,18 +1419,76 @@ impl GitPanel { } fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.amend_pending { + return; + } if self .commit_editor .focus_handle(cx) .contains_focused(window, cx) { telemetry::event!("Git Committed", source = "Git Panel"); - self.commit_changes(window, cx) + self.commit_changes(CommitOptions { amend: false }, window, cx) } else { cx.propagate(); } } + fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.active_repository.as_ref() else { + return; + }; + let Some(branch) = active_repository.read(cx).branch.as_ref() else { + return; + }; + let Some(recent_sha) = branch + .most_recent_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + return; + }; + if self + .commit_editor + .focus_handle(cx) + .contains_focused(window, cx) + { + if !self.amend_pending { + self.amend_pending = true; + cx.notify(); + if self.commit_editor.read(cx).is_empty(cx) { + let detail_task = self.load_commit_details(recent_sha, cx); + cx.spawn(async move |this, cx| { + if let Ok(message) = detail_task.await.map(|detail| detail.message) { + this.update(cx, |this, cx| { + this.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, message)], None, cx); + }); + }) + .log_err(); + } + }) + .detach(); + } + } else { + telemetry::event!("Git Amended", source = "Git Panel"); + self.amend_pending = false; + self.commit_changes(CommitOptions { amend: true }, window, cx); + } + } else { + cx.propagate(); + } + } + + fn cancel(&mut self, _: &git::Cancel, _: &mut Window, cx: &mut Context) { + if self.amend_pending { + self.amend_pending = false; + cx.notify(); + } + } + fn custom_or_suggested_commit_message(&self, cx: &mut Context) -> Option { let message = self.commit_editor.read(cx).text(cx); @@ -1440,7 +1500,12 @@ impl GitPanel { .filter(|message| !message.trim().is_empty()) } - pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn commit_changes( + &mut self, + options: CommitOptions, + window: &mut Window, + cx: &mut Context, + ) { let Some(active_repository) = self.active_repository.clone() else { return; }; @@ -1474,8 +1539,9 @@ impl GitPanel { let task = if self.has_staged_changes() { // Repository serializes all git operations, so we can just send a commit immediately - let commit_task = - active_repository.update(cx, |repo, cx| repo.commit(message.into(), None, cx)); + let commit_task = active_repository.update(cx, |repo, cx| { + repo.commit(message.into(), None, options, cx) + }); cx.background_spawn(async move { commit_task.await? }) } else { let changed_files = self @@ -1495,8 +1561,9 @@ impl GitPanel { active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx)); cx.spawn(async move |_, cx| { stage_task.await?; - let commit_task = active_repository - .update(cx, |repo, cx| repo.commit(message.into(), None, cx))?; + let commit_task = active_repository.update(cx, |repo, cx| { + repo.commit(message.into(), None, options, cx) + })?; commit_task.await? }) }; @@ -2722,6 +2789,34 @@ impl GitPanel { } } + fn render_git_commit_menu( + &self, + id: impl Into, + keybinding_target: Option, + ) -> impl IntoElement { + PopoverMenu::new(id.into()) + .trigger( + ui::ButtonLike::new_rounded_right("commit-split-button-right") + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + ), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .when_some(keybinding_target.clone(), |el, keybinding_target| { + el.context(keybinding_target.clone()) + }) + .action("Amend...", Amend.boxed_clone()) + })) + }) + .anchor(Corner::TopRight) + } + pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { if self.has_unstaged_conflicts() { (false, "You must resolve conflicts before committing") @@ -2739,10 +2834,18 @@ impl GitPanel { } pub fn commit_button_title(&self) -> &'static str { - if self.has_staged_changes() { - "Commit" + if self.amend_pending { + if self.has_staged_changes() { + "Amend" + } else { + "Amend Tracked" + } } else { - "Commit Tracked" + if self.has_staged_changes() { + "Commit" + } else { + "Commit Tracked" + } } } @@ -2885,6 +2988,10 @@ impl GitPanel { let editor_is_long = self.commit_editor.update(cx, |editor, cx| { editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32 }); + let has_previous_commit = branch + .as_ref() + .and_then(|branch| branch.most_recent_commit.as_ref()) + .is_some(); let footer = v_flex() .child(PanelRepoFooter::new(display_name, branch, Some(git_panel))) @@ -2920,32 +3027,140 @@ impl GitPanel { .unwrap_or_else(|| div().into_any_element()), ) .child( - h_flex().gap_0p5().children(enable_coauthors).child( - panel_filled_button(title) - .tooltip(move |window, cx| { - if can_commit { - Tooltip::for_action_in( - tooltip, - &Commit, - &commit_tooltip_focus_handle, - window, - cx, + h_flex() + .gap_0p5() + .children(enable_coauthors) + .when(self.amend_pending, { + |this| { + this.h_flex() + .gap_1() + .child( + panel_filled_button("Cancel") + .tooltip({ + let handle = + commit_tooltip_focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Cancel amend", + &git::Cancel, + &handle, + window, + cx, + ) + } + }) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(git::Cancel), + cx, + ); + }), ) - } else { - Tooltip::simple(tooltip, cx) - } + .child( + panel_filled_button(title) + .tooltip({ + let handle = + commit_tooltip_focus_handle.clone(); + move |window, cx| { + if can_commit { + Tooltip::for_action_in( + tooltip, &Amend, &handle, + window, cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }) + .disabled(!can_commit || self.modal_open) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(git::Amend), + cx, + ); + }), + ) + } + }) + .when(!self.amend_pending, |this| { + this.when(has_previous_commit, |this| { + this.child(SplitButton::new( + ui::ButtonLike::new_rounded_left(ElementId::Name( + format!("split-button-left-{}", title).into(), + )) + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::Compact) + .child( + div() + .child( + Label::new(title) + .size(LabelSize::Small), + ) + .mr_0p5(), + ) + .on_click(move |_, window, cx| { + window + .dispatch_action(Box::new(git::Commit), cx); + }) + .disabled(!can_commit || self.modal_open) + .tooltip({ + let handle = + commit_tooltip_focus_handle.clone(); + move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &handle.clone(), + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }), + self.render_git_commit_menu( + ElementId::Name( + format!("split-button-right-{}", title) + .into(), + ), + Some(commit_tooltip_focus_handle.clone()), + ) + .into_any_element(), + )) }) - .disabled(!can_commit || self.modal_open) - .on_click({ - cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!( - "Git Committed", - source = "Git Panel" - ); - this.commit_changes(window, cx) - }) - }), - ), + .when( + !has_previous_commit, + |this| { + this.child( + panel_filled_button(title) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &commit_tooltip_focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + }) + .disabled(!can_commit || self.modal_open) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(git::Commit), + cx, + ); + }), + ) + }, + ) + }), ), ) .child( @@ -2994,6 +3209,17 @@ impl GitPanel { Some(footer) } + fn render_pending_amend(&self, cx: &mut Context) -> impl IntoElement { + div() + .py_2() + .px(px(8.)) + .border_color(cx.theme().colors().border) + .child( + Label::new("Your changes will modify your most recent commit. If you want to make these changes as a new commit, you can cancel the amend operation.") + .size(LabelSize::Small), + ) + } + fn render_previous_commit(&self, cx: &mut Context) -> Option { let active_repository = self.active_repository.as_ref()?; let branch = active_repository.read(cx).branch.as_ref()?; @@ -3448,7 +3674,7 @@ impl GitPanel { .into_any_element() } - fn load_commit_details( + pub fn load_commit_details( &self, sha: String, cx: &mut Context, @@ -3766,6 +3992,14 @@ impl GitPanel { fn has_write_access(&self, cx: &App) -> bool { !self.project.read(cx).is_read_only(cx) } + + pub fn amend_pending(&self) -> bool { + self.amend_pending + } + + pub fn set_amend_pending(&mut self, value: bool) { + self.amend_pending = value; + } } fn current_language_model(cx: &Context<'_, GitPanel>) -> Option> { @@ -3806,6 +4040,8 @@ impl Render for GitPanel { .when(has_write_access && !project.is_read_only(cx), |this| { this.on_action(cx.listener(Self::toggle_staged_for_selected)) .on_action(cx.listener(GitPanel::commit)) + .on_action(cx.listener(GitPanel::amend)) + .on_action(cx.listener(GitPanel::cancel)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) .on_action(cx.listener(Self::stage_selected)) @@ -3852,7 +4088,12 @@ impl Render for GitPanel { } }) .children(self.render_footer(window, cx)) - .children(self.render_previous_commit(cx)) + .when(self.amend_pending, |this| { + this.child(self.render_pending_amend(cx)) + }) + .when(!self.amend_pending, |this| { + this.children(self.render_previous_commit(cx)) + }) .into_any_element(), ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 5edceb90fe..ac0a1ef859 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -368,6 +368,7 @@ mod remote_button { }) .anchor(Corner::TopRight) } + #[allow(clippy::too_many_arguments)] fn split_button( id: SharedString, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index b917295ec1..ddc610f755 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -21,7 +21,7 @@ use git::{ blame::Blame, parse_git_remote_url, repository::{ - Branch, CommitDetails, CommitDiff, CommitFile, DiffType, GitRepository, + Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, UpstreamTrackingStatus, }, @@ -1656,10 +1656,18 @@ impl GitStore { let message = SharedString::from(envelope.payload.message); let name = envelope.payload.name.map(SharedString::from); let email = envelope.payload.email.map(SharedString::from); + let options = envelope.payload.options.unwrap_or_default(); repository_handle .update(&mut cx, |repository_handle, cx| { - repository_handle.commit(message, name.zip(email), cx) + repository_handle.commit( + message, + name.zip(email), + CommitOptions { + amend: options.amend, + }, + cx, + ) })? .await??; Ok(proto::Ack {}) @@ -3248,6 +3256,7 @@ impl Repository { &mut self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, + options: CommitOptions, _cx: &mut App, ) -> oneshot::Receiver> { let id = self.id; @@ -3258,7 +3267,11 @@ impl Repository { backend, environment, .. - } => backend.commit(message, name_and_email, environment).await, + } => { + backend + .commit(message, name_and_email, options, environment) + .await + } RepositoryState::Remote { project_id, client } => { let (name, email) = name_and_email.unzip(); client @@ -3268,6 +3281,9 @@ impl Repository { message: String::from(message), name: name.map(String::from), email: email.map(String::from), + options: Some(proto::commit::CommitOptions { + amend: options.amend, + }), }) .await .context("sending commit request")?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 7774a5293b..0d94bcb469 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -292,6 +292,11 @@ message Commit { optional string name = 4; optional string email = 5; string message = 6; + optional CommitOptions options = 7; + + message CommitOptions { + bool amend = 1; + } } message OpenCommitMessageBuffer { diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 6ceeb88377..3d50340755 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -20,6 +20,12 @@ pub struct SplitButton { pub right: AnyElement, } +impl SplitButton { + pub fn new(left: ButtonLike, right: AnyElement) -> Self { + Self { left, right } + } +} + impl RenderOnce for SplitButton { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { h_flex() From a0511941958aa9f0ad4d688bd0a69ca8c2926bfc Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 14 Apr 2025 09:50:01 -0600 Subject: [PATCH 44/75] agent: Check built-in tools schema compatibility in tests (#28691) This ensures that we respect the `LanguageModelToolSchemaFormat` value when we call `tool.input_schema`. This prevents us from breaking Gemini compatibility when adding/changing built-in tools. See #28634. The test suite will now fail with an error message like this, when providing an incompatible input_schema: ``` thread 'tests::test_tool_schema_compatibility' panicked at crates/assistant_tools/src/assistant_tools.rs:108:17: Tool schema for `code_actions` is not compatible with `language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset` (Gemini Models). Are you using `schema::json_schema_for(format)` to generate the schema? ``` Release Notes: - N/A --- crates/assistant_tools/src/assistant_tools.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index c0fd4f81c5..bede5866ef 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -76,3 +76,37 @@ pub fn init(http_client: Arc, cx: &mut App) { registry.register_tool(ThinkingTool); registry.register_tool(FetchTool::new(http_client)); } + +#[cfg(test)] +mod tests { + use http_client::FakeHttpClient; + + use super::*; + + #[gpui::test] + fn test_tool_schema_compatibility(cx: &mut App) { + crate::init( + Arc::new(http_client::HttpClientWithUrl::new( + FakeHttpClient::with_200_response(), + "https://zed.dev", + None, + )), + cx, + ); + + for tool in ToolRegistry::global(cx).tools() { + let schema = + tool.input_schema(language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset); + assert!(schema.is_object()); + if schema.as_object().unwrap().contains_key("$schema") { + let error_message = format!( + "Tool schema for `{}` is not compatible with `language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset` (Gemini Models).\n\ + Are you using `schema::json_schema_for(format)` to generate the schema?", + tool.name() + ); + + panic!("{}", error_message) + } + } + } +} From 584fa3db533fde8fdfd65215d15a58e71af39e35 Mon Sep 17 00:00:00 2001 From: Evan Gibler <20933572+egibs@users.noreply.github.com> Date: Mon, 14 Apr 2025 11:40:13 -0500 Subject: [PATCH 45/75] docs: Add Yara language extension (#28693) This PR adds a quick overview of the Yara language extension in order to display the language on the Zed [site](https://zed.dev/docs/languages). Release Notes: - N/A --------- Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> Co-authored-by: Marshall Bowers --- docs/src/SUMMARY.md | 1 + docs/src/languages.md | 1 + docs/src/languages/yara.md | 6 ++++++ 3 files changed, 8 insertions(+) create mode 100644 docs/src/languages/yara.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index ab1c6f5d09..9064370fe0 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -127,6 +127,7 @@ - [Vue](./languages/vue.md) - [XML](./languages/xml.md) - [YAML](./languages/yaml.md) +- [Yara](./languages/yara.md) - [Yarn](./languages/yarn.md) - [Zig](./languages/zig.md) diff --git a/docs/src/languages.md b/docs/src/languages.md index 4e7f05bb57..faee42176c 100644 --- a/docs/src/languages.md +++ b/docs/src/languages.md @@ -71,6 +71,7 @@ Some work out-of-the box and others rely on 3rd party extensions. - [Vue](./languages/vue.md) - [XML](./languages/xml.md) - [YAML](./languages/yaml.md) \* +- [Yara](./languages/yarn.md) - [Yarn](./languages/yarn.md) - [Zig](./languages/zig.md) diff --git a/docs/src/languages/yara.md b/docs/src/languages/yara.md new file mode 100644 index 0000000000..f95ab2f778 --- /dev/null +++ b/docs/src/languages/yara.md @@ -0,0 +1,6 @@ +# Yara + +`Yara` language support in Zed is provided by the [Yara](https://github.com/egibs/yara.zed) extension. Please report issues to [https://github.com/egibs/yara.zed/issues](https://github.com/egibs/yara.zed). + +- Tree-sitter: [egibs/tree-sitter-yara](https://github.com/egibs/tree-sitter-yara) +- Language Server: [avast/yls](https://github.com/avast/yls) From 1d9915f88ac91bbfaf26f00efd8a8841d9cde44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Tue, 15 Apr 2025 01:36:31 +0800 Subject: [PATCH 46/75] windows: Implement `AutoUpdater` (#25734) Part of #24800 https://github.com/user-attachments/assets/e70d594e-3635-4f93-9073-5abf7e9d2b20 Release Notes: - N/A --- Cargo.lock | 14 ++ Cargo.toml | 12 +- crates/auto_update/Cargo.toml | 4 +- crates/auto_update/src/auto_update.rs | 101 +++++++- crates/auto_update_helper/Cargo.toml | 29 +++ crates/auto_update_helper/LICENSE-GPL | 1 + crates/auto_update_helper/app-icon.ico | Bin 0 -> 590611 bytes crates/auto_update_helper/build.rs | 15 ++ crates/auto_update_helper/manifest.xml | 16 ++ .../src/auto_update_helper.rs | 94 +++++++ crates/auto_update_helper/src/dialog.rs | 236 ++++++++++++++++++ crates/auto_update_helper/src/updater.rs | 171 +++++++++++++ crates/zed/src/main.rs | 51 ++-- crates/zed/src/zed/windows_only_instance.rs | 10 +- tooling/workspace-hack/Cargo.toml | 4 + 15 files changed, 721 insertions(+), 37 deletions(-) create mode 100644 crates/auto_update_helper/Cargo.toml create mode 120000 crates/auto_update_helper/LICENSE-GPL create mode 100644 crates/auto_update_helper/app-icon.ico create mode 100644 crates/auto_update_helper/build.rs create mode 100644 crates/auto_update_helper/manifest.xml create mode 100644 crates/auto_update_helper/src/auto_update_helper.rs create mode 100644 crates/auto_update_helper/src/dialog.rs create mode 100644 crates/auto_update_helper/src/updater.rs diff --git a/Cargo.lock b/Cargo.lock index ef35a57f28..911ea64417 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,18 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "auto_update_helper" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "simplelog", + "windows 0.61.1", + "winresource", + "workspace-hack", +] + [[package]] name = "auto_update_ui" version = "0.1.0" @@ -17767,6 +17779,8 @@ dependencies = [ "wasmtime-cranelift", "wasmtime-environ", "winapi", + "windows-core 0.61.0", + "windows-numerics", "windows-sys 0.48.0", "windows-sys 0.52.0", "windows-sys 0.59.0", diff --git a/Cargo.toml b/Cargo.toml index 70634e87bc..ba011f20e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/assistant_tools", "crates/audio", "crates/auto_update", + "crates/auto_update_helper", "crates/auto_update_ui", "crates/aws_http_client", "crates/bedrock", @@ -222,6 +223,7 @@ assistant_tool = { path = "crates/assistant_tool" } assistant_tools = { path = "crates/assistant_tools" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } +auto_update_helper = { path = "crates/auto_update_helper" } auto_update_ui = { path = "crates/auto_update_ui" } aws_http_client = { path = "crates/aws_http_client" } bedrock = { path = "crates/bedrock" } @@ -782,4 +784,12 @@ let_underscore_future = "allow" too_many_arguments = "allow" [workspace.metadata.cargo-machete] -ignored = ["bindgen", "cbindgen", "prost_build", "serde", "component", "linkme", "workspace-hack"] +ignored = [ + "bindgen", + "cbindgen", + "prost_build", + "serde", + "component", + "linkme", + "workspace-hack", +] diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 84b4e5d739..1a772710c9 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -27,6 +27,8 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true tempfile.workspace = true -which.workspace = true workspace.workspace = true workspace-hack.workspace = true + +[target.'cfg(not(target_os = "windows"))'.dependencies] +which.workspace = true diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 77d2037288..390400c048 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -23,7 +23,6 @@ use std::{ sync::Arc, time::Duration, }; -use which::which; use workspace::Workspace; const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification"; @@ -63,7 +62,7 @@ pub struct AutoUpdater { pending_poll: Option>>, } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub struct JsonRelease { pub version: String, pub url: String, @@ -237,6 +236,46 @@ pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> { None } +#[cfg(not(target_os = "windows"))] +struct InstallerDir(tempfile::TempDir); + +#[cfg(not(target_os = "windows"))] +impl InstallerDir { + async fn new() -> Result { + Ok(Self( + tempfile::Builder::new() + .prefix("zed-auto-update") + .tempdir()?, + )) + } + + fn path(&self) -> &Path { + self.0.path() + } +} + +#[cfg(target_os = "windows")] +struct InstallerDir(PathBuf); + +#[cfg(target_os = "windows")] +impl InstallerDir { + async fn new() -> Result { + let installer_dir = std::env::current_exe()? + .parent() + .context("No parent dir for Zed.exe")? + .join("updates"); + if smol::fs::metadata(&installer_dir).await.is_ok() { + smol::fs::remove_dir_all(&installer_dir).await?; + } + smol::fs::create_dir(&installer_dir).await?; + Ok(Self(installer_dir)) + } + + fn path(&self) -> &Path { + self.0.as_path() + } +} + impl AutoUpdater { pub fn get(cx: &mut App) -> Option> { cx.default_global::().0.clone() @@ -469,22 +508,21 @@ impl AutoUpdater { cx.notify(); })?; - let temp_dir = tempfile::Builder::new() - .prefix("zed-auto-update") - .tempdir()?; - + let installer_dir = InstallerDir::new().await?; let filename = match OS { "macos" => Ok("Zed.dmg"), "linux" => Ok("zed.tar.gz"), + "windows" => Ok("ZedUpdateInstaller.exe"), _ => Err(anyhow!("not supported: {:?}", OS)), }?; + #[cfg(not(target_os = "windows"))] anyhow::ensure!( - which("rsync").is_ok(), + which::which("rsync").is_ok(), "Aborting. Could not find rsync which is required for auto-updates." ); - let downloaded_asset = temp_dir.path().join(filename); + let downloaded_asset = installer_dir.path().join(filename); download_release(&downloaded_asset, release, client, &cx).await?; this.update(&mut cx, |this, cx| { @@ -493,8 +531,9 @@ impl AutoUpdater { })?; let binary_path = match OS { - "macos" => install_release_macos(&temp_dir, downloaded_asset, &cx).await, - "linux" => install_release_linux(&temp_dir, downloaded_asset, &cx).await, + "macos" => install_release_macos(&installer_dir, downloaded_asset, &cx).await, + "linux" => install_release_linux(&installer_dir, downloaded_asset, &cx).await, + "windows" => install_release_windows(downloaded_asset).await, _ => Err(anyhow!("not supported: {:?}", OS)), }?; @@ -629,7 +668,7 @@ async fn download_release( } async fn install_release_linux( - temp_dir: &tempfile::TempDir, + temp_dir: &InstallerDir, downloaded_tar_gz: PathBuf, cx: &AsyncApp, ) -> Result { @@ -696,7 +735,7 @@ async fn install_release_linux( } async fn install_release_macos( - temp_dir: &tempfile::TempDir, + temp_dir: &InstallerDir, downloaded_dmg: PathBuf, cx: &AsyncApp, ) -> Result { @@ -743,3 +782,41 @@ async fn install_release_macos( Ok(running_app_path) } + +async fn install_release_windows(downloaded_installer: PathBuf) -> Result { + let output = Command::new(downloaded_installer) + .arg("/verysilent") + .arg("/update=true") + .arg("!desktopicon") + .arg("!quicklaunchicon") + .output() + .await?; + anyhow::ensure!( + output.status.success(), + "failed to start installer: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(std::env::current_exe()?) +} + +pub fn check_pending_installation() -> bool { + let Some(installer_path) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.join("updates"))) + else { + return false; + }; + + // The installer will create a flag file after it finishes updating + let flag_file = installer_path.join("versions.txt"); + if flag_file.exists() { + if let Some(helper) = installer_path + .parent() + .map(|p| p.join("tools\\auto_update_helper.exe")) + { + let _ = std::process::Command::new(helper).spawn(); + return true; + } + } + false +} diff --git a/crates/auto_update_helper/Cargo.toml b/crates/auto_update_helper/Cargo.toml new file mode 100644 index 0000000000..6581de48d2 --- /dev/null +++ b/crates/auto_update_helper/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "auto_update_helper" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[[bin]] +name = "auto_update_helper" +path = "src/auto_update_helper.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +log.workspace = true +simplelog.workspace = true +workspace-hack.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + +[target.'cfg(target_os = "windows")'.build-dependencies] +winresource = "0.1" + +[package.metadata.docs.rs] +targets = ["x86_64-pc-windows-msvc"] diff --git a/crates/auto_update_helper/LICENSE-GPL b/crates/auto_update_helper/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/auto_update_helper/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/auto_update_helper/app-icon.ico b/crates/auto_update_helper/app-icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..321e90fcfa15d8f84c2619b4d12af892ea5cda66 GIT binary patch literal 590611 zcmeF42b`Wowf{GDd*AG)Y_dJu-|g9iPy&GvTIkYS2rZ;i(&)W6>4e@PARs7;AfPCE zuZmO~C@6X@2#V!;uini6_dWB>`#!tbka8Eq```DoXXh#NJkQMUoH;XdX68&<+OV|D zw1R>(g_UW?j!a9tE-fu>>{x$(VP;y|T3xHG^yl|0-uZcHY3=RNd0kpsq9QGA#teVn zuw7c(8{4O)9e#Lp{~A4iep=d@XGZrkhNay*VOSbJzSmGWGVOac!;|L|pBiu`FXi6Hz(UDM2J>xzVFXi$=NZ>@1g`pYL*cPe#Tz;T<_SIrDk%==5~gnn<`+t4?vB`OJQ9pMCam zbLQ;sX799<+hw;ouA;KazVE-2w=g`Pmshxe_onL`wRN@bp@$!Kt5&Xbha7aEJMe)0 z-5$H|?zY%sx|=z3rpwC8c7;X7q;*kvzOcA>L2+@B%g#)92OM~S>zOjeeeQFgbIX>U z>~`9DXSdTXySOd4+{$%!cDg)$r({g2D=schet*oEF$>3zEmOX7-0{aRaEBjuxSKU= zmb?Ff2i)AbN4oNflU#d8muqfram6JiZtS>mZtU2x3&Quuj~~Bq;>2=SR8-`SKIT|= z%E}dPpS|~V7hiOtd;IawyN1b=U1@2ltE;PXnI7;XtgoxRHPPDa z+H^0GXj9nk+S@x^M`u?=-QsJno4&;suDh$_j`*|TxTU3Gi?)`gV-tzy`R!19LSbTl zqM7r={EkH8xbE)uKHm+`{D@{Fzmb)du?k(B zt#srUSJ?RcrCXiwnZvJt{xf7Nybqg0PaK{{dv^(O6 zBiw-p9_S7^F8we{AnrKQ=hFD@=o z9W#b+^r>gbOL6g-he~y?bWE{iEZyy`I`Yb^uCnxOyWO_#x#ym9H{WuLTfBIQ>AQx; zCeMed(_CX?gVoX0U!|pGZcOQzb>3fEI`-jlW5>DiZuadF=b=NT0NyYZk_j!9XH`2uqdA} z-sE$ieLmyPI`d4oRQ1K&BMx)(j-0EyXPJBJtvB6N$yJf~SX)!8?=-7^sixj^<5dTh zPn_r`Oqj6F`&Ab{RG~VqqGBR-wA)whh;z<9Tj@X1?YG}P%ENwc_3E44U3cB-zWL2> z+BYaulU2V|s@}1*l}}V#qmnvuo%c_iSn+VRFsQ1kaH{v+e)}Kb&OGZZw`A!O_s~NR zS^HtG^01%2bNvlBxc~m||8`q%wUx?quAZq>x+?Tu>bGjJT<8526_pRy*3>A^Rj#b8 z%pIn-#ATOVX65ok@nEIeMkg;@;g+pf=@u+nEV)0`J^$SEZU?n(%GEX?ZK|7HjozVf z9r>)Ns(z4mME&GiH%V=dnUcM4ed}AQpD%ako^y`7;DQU?1s7iAE;PE>oqqZmhSM*8 z{&UscN|);8I+bPcs;;S7=lxYRwRhG{u6L8`>s(_~gS81}?XbN&YTjJ6iw+hY;toGl zq>yrU)Vw1s+0q zlf*Mm@CK@`u2j5gOAGO)^iEY7?y_$rbgp*CivI5xZl!g#dbipr^-4#h~@{qy{@af$Mvd>V)xxS{hGIv^L(J zXlcF=YD=`--`?8#K%lnPmIt^dy04=xabJ6T%N<=EZOgaVVvEx7o}qB#5TG4aDViao zU&!xFI)FpJO+|ptbe1T46NzU=h&Jn&m6bDJZI0*FPJBBnGwU~*nOVOz%2H^Q9Sb?m z{;lGsC_DQ%YJ0q+KK9qrGcy-#LLy4kR(pju5pBZI-po-uCPixVsZE(A+JKxBCds!y zY4>I4*dK-b# zwKiL6BOSu+Wu-ypfPsIsk$qcL&*tgdYJV0LdMZ>{ptdpX(R{Vj@*t&ceT3ZtqTQ_e zTxq~|VeRCD2mZQ*EGz3#`@Yg^?R~zl_Hv=xZrDAtMYJcC54Df8EaVu{v$_{ZI4PaJ z{cP=8vjxzGRyv+o-}jZiSHOw>0wK1ALfHUlSLaC{%G3@Uqjp+JiR=v}W7M80QTkF; zsADK!?Okjq*hQ?}Yj&8Bj$Gtmz@PH`W{&zf#xKJ_wwqzY+!<$_>HhGCKe%U~ea^l3 z;;dg>|n`OkmeJ^0`nwVm#At8c#9U3=|y?y9Rk>#n}$8n@eSbCiY*wdo42 zPhg?aA-l}a2K=}#DvxjF<$<-A9e6iVeUt5X*un9eJ9n-dt-eWVNwF(adzWyG_*0^G z9c?@EN;@2z(fQ|}Z{L+18HS+`AWYs~-}jZqx2!LqXOR0s@etlCzT0NT4EMX={my;% zvsbD8c$T~TvP<20=bqzEJ!O?U@x&9{;zf&OCz)&Qbjt3DC!T0&!VX3Mpin#^ZA#;M zzppqy#pY8aoQfsyxw%yGZPfqR*YbJDA%|FdxUi_uFe%Zu$Ecs7 z_pkT+lIypzYn6_XO{{E;a4WF3{VT7$qPBgxTe4({`jtDlX|mby`{gfxskZ5b?uHv~ zbd^;#u3POP`kVA;_S|z%vyow6qs;K_QnjB&>-~ON+1R(~Phj_&Fn+AzhOLh8m+2jM z-Fc_G;QaF}uhXYbckjRdf%VyHs%zA?tdWhW!`hVUGr8@z-@)v9Me zAMTpex5K8$cc!UbNQiAwwn35JD-2~XT<`aF|LuwDM@=lBpgze&d$vmXdiB*;U4{Av zt8co|ophr5k=-4J{};aSguefQ9naZySC^fYY5k1~*&`DwgXD$&(*#K0R{x3f^?qM{ zwYMvyz8dXy`gPScb@u%#^~G+x?N+y9#WKUcySqy|W>@uTyWN56qyOeNzcIURw)Ak7 z`a8AioA5pIL*H(i?BQ!TtNc|GQhU za)n#IV!6>u*;S8UaJ+l^nWqhhJ@?pCIAj?Pq=WvQ`fy4MeGu!5t@rzi_cndn8u}@< z)t2`9`bPK08*i9=HK_k_)9Tgkjyvyix88Ppl5W4_F7^4ZmJYtf{r>mAcYE)(m+25} zxS{_?I_Lu`4eR}WWmWZ0iBl^(>tyu@;RSv8ci(;2?WOWWACLR>D(?*qO|GG_*~t1r zO)Zwklce|f9dgh?Zle11*mvo_k`C;|%HMjwFIoEu{kXci8rRq`S$)#UvOA2Eo;}z7 zN@e{AKX}8v@%roT^#KTse(-}IxbKKRfBMsh?#e4Jm&{IZ&9ZScs_z#1s7n9O($@2n zti24s88fKY_ZyoUWoKx#ve?qxs6Je?l|A}t97EP;(|Kom!u51@xZd7w_1DyYYim_G zQGczy&9%yojm@6?DF3go=ksaR)wOHTr8V@Y>nA7svyJN4lBPQG1Z)W5Prdq#_4U}# zmDa||($Nj-iz;3EW|#Ve*x~5kw#x?I($uuJS$Po;9$DY_We3<5zQU)v$&=S&D{2s5 zt-l-fbwi)GxwTb&Ugb@cAdd>$Iy+rE-;-UwL%O)Dr&oR89{R3pNdtEJ#>tcST;KP( zu5UlnqIA~F#!sKWQE6zDtp}nGGFemKySXJHQr|nlx7F{K?rlv{r)%p_8oGLv{~qz7 z+fAP~RlI6_{-eGfeLuaiuJ+mXh`;I+TVFh(FrhrB`sT86k*BDCP9L5AdOPP)AH7>R zV6)m%_4btRuCGfcZ?q-8rE&6}iPnZSiI%3963tCNQh)u&lDD5IJwK7my(v4vTguN{ z>Z`xi-q!k-?!VdArh9FzKT+TP#|nR>@TJbqwudB>dv7S;L-+sxL;yPlV?~FHl6H!Z z91T&#q2CY!LkO%7feekGb!v{oKBF=+j@H=XqV)94r5{O|x(|hT;XEGNJ>HdmjNZMk zzR{%wY;=9HzQo3^=g8)Dc6N5|+t@%D(+hS0AIkoLU1CF|XM+vLhuELiB^wjX%Kmvq zR+eVdq-h4u=Kn^G%Ia2ne~FC*+ZDE(Tz%8VMzQ-ugbirpX@IA>A4S^@Pm_n;Z9XBAZ(jk{|4BVLndC-#X{y+u}v`L8U#|49SPt znaGdwqC7C}+r5c-7$w`+&#+I4)?#bP$95vSiIHq5N&8{ahKL;z!j>40eaL*7T(c{B z`w#rHbGt^KQ`aqD5kF(s#+oRbZu`Pb24N`d`4n#i5zm5ZOjr{uWd%3hSmzjCu#-?9m`>!C+*d&o5?2Ezf zSR~sCR4n^TkqA3h0i^se4*ecQ_<GyU{@R!|EX=bhhwkvPHk{wpIG3FOwOVqf%(hiXiXrS|O&xe-pSpE$I$!o|% zj2{Dz)5Y^8dv6!x;ozPsN8$lxKzsE)woLkk&AFBjmx}q4e_&_|c|*&Ebb1;pQGW z&xm8@R~&Tk!DegRZ@>NAzWaX0?JfJ`9((TP=4fsma~XEl{D|2*?d)dlxRcv)_D(hj z1REr_a%`cN{yw}Ek0R_8?h?m(pkt+TSNLxBYIL#bHt7f}YZ~v6EuQ%-*e>{Cr~0q| z`Y-plzx~bTUA+JP`)0@d`8)5pH#MK+_19l>-}~P8Z0!4MU;CQo$UQB)>yxr^KkgoW z_;Z?f_k_Fj(#yoxV$+w{lRZW9DETwKd;aV-P@3b{l>Rx$F>_nc`{-q_?^1e$xjCnt zda9+L@pnBhyJ(r&M8;#MEGyNyNH$_W2Lv0d$ZWWj8|ICa3fFv_UvSSo_t?Be>3PH7 z_+Vv9dBILkxq~P9`TNAvI?!=$LBa0ipK@pQ7P?2+GiM3?h`z~{ERyDxD_2<_mM!zP zJFblwHUJIRwc*3uh!Mk$h&N2-fb=4(j4f=Xxf9GEMHiSo-E8XEx&1f;_VbNNzw){V zyfk}1@~-zY|A)C(=z4S}@;Hn#sB~ak=Z7tnc_P@MUVi!e?%Utic!=iMeCbP1x${?& z4dbu$?@4)rhlTJ>dX2HN;v$98`y=&D=F7gObYk}=56tD{$GjuPN3Om0TG2J`a?SO) z;DYnrnVNTlJ@Z7(jX73xtFF=gvou$Rd1Bb_&(ge>-rgyiXOv;One_WHC_lacKkR&? z(qFunACJ(pk~!+BlH!O;iY&eJH5ZECMA=S92{ZC=$|K(>uNGsQk#2`|dB@Yp=bgxn|G1d+)p7Y{H|{Gi{Fc5t{Fej%EzzNIlyo zn;GRwbIn9btFGIarln(yjgxF-`uAmA#m0;D9>$BtjV;wUhxDRs#v?|Ka>r=yG{5m0 z2Lby({_&4iMgt$tIO7bvkDN1}n@F^)TwGvtYt#?2It$+9-zWh$o_{%8i^tazu`ZdpP z^r#V<+tn_4UZ{Mf*}Qr(dbacnd6=N|Fs~eY>a?lTlpgU{>7O}sdmDQ|X5q0XeP3}c zElNAzCT)7pNB)RYu6ccaTukFs8mlN*`fdD6<&`qW+1G1-waD{_{Y4&@HND+Ee|z} zyD43aCol$Qn1Qo!V{FjI2Q3|wm?ODS=~ueRQqQ1NR^k-Zsh1uc5m78<(BUYR7X=+F^&iiJ^61MZu&Md z05QHvs5zJlCv8xEeEO?>`X%3tuhwYH6aF&hOS^92!o`-3+S)ps5B}zxZ`wSNTW-BY zdj4K_-~IQgy(7B+ep}n}z=IFEd+xp0U48Y{?$3YzvpsvR#_qDz2O{lZ9FY8gGvlHu zs<3N}FK$fwCrp^MFJtcT6PdBOoth)dxGxycwqB(9u>2Y{KU6jX8z+A2t+(8-e)TK& z&O1MM?`RHdk~kOr;upWLYv23cOYRMgC-S>Ua|~#k*Xg~CsWLw5=lmj9evFs=kQTgL4C z%H3-;51VI@8^)_iKjV^&6Vtw;y~1zXZMJcg!PXXy;V~ZD*l6<f(iA&wd zny*B7l7%`xLD$sgXY8J9%M_P+CM%XNbEm9Y=~im41J`!jb!U5Dr^b6V_NXysjgO0$ z@B%*A7`spZP8*UR@u-cl_G)B^awT3eN4x>~;Jr$Bv#3U8ns(jTGUfz{)V>}!c8uB^ zWfpQgUiT+xeZquss#itj6UM537$+W)BBHi)w50s;X=MQB&*l5XS78BX0B1)y|@=)!d@-bLEG;@ckyufhP>q z($c6lCHEREOfa{gjWOpIjU#J(S=80tsj+;eTWO!tJ4N9X>j!jnbXfbMMc+y0LFrfh zRSFJayQ`C;yYg*s;5LW{l+CDgu2w0GEXo3OR{44qmZ zV5Ip3-CAoX>V!0}pp$tHibuPMc>>ID&>Wq^H#YsmtFNp3F6~5x(gVKyQ(uvXFz>;y zIf!@>)*TQs4?=4X$cxQ`klt(+F&84y-f8n8e5f@E?V5|yHN~}eD&0}~yOjPe z)@ptN^Hg5jl(Z99b;nrMDSvE{TsKNDOMk9Kub~&3k$+@Kd1-1=c@QOazGSOaX*d0^ zr&qI^@+Pkhx!kj#8%T5tEs&RUnUi*)3!ot=r@IueOF9f{^STBAFsqa!g#ad*=*ySBHq?4oro zJ3}2Etvl)dY&|ne$J=!2MVc3tKNx8pddCm~LkJ8Z@Npx+Jgpg`GeyIq^<-(=>160P zguoC2LkJ8ZFjxq%J~2bYoHSPK(wF`?iEr>N=JN6Ff5xvuW9kQK{pFQfn*a6( z<}g4xIjozF&GXYZEH+n9{jYz-Iqz8KJ?qYc9B6K&&Bx2m{!sp0Zv41#7@e89m-707 z&6~V%v5Ju5_D)E7J%i_e~5UxkTYvvCH zzUOhC%N!Pw(&y(IM)NMk|A5EFgviRk@xXyOnSS11n2SkhSj0l+W7(W6;gF^Hnya{Q zbMqU2PVdh`B(1 z?y3)MUQ>h%^PDzBVcsh7$YY4_=eX(`*Q57@;~;ZhE_``Y{tW{^zcu?0%JW9qb_c>n za({YqzG)-_ee)m9do2i|pZ6N(zQ*Uj+S>ZOXpOx>Ki4%icg>%3ooj)j@@EjWtcc~W00y~u~;c$-biydo?Ai+NATf8c*o2PiEz z|IzaA^Bv}hG0)A<)$2FsQ#j;7`4L6O0iS@2-M4!wtinA%CmB4!!ei#~P+XG-+9fF+ z2_}+(b^H`<2oKFyzLt4oKL4R^Nyz}|@iM@Caphn0*_e-L*JF8PUPE%ut<}+?O~bpw zu|n~!&$-oO(e>ya?+Nb*Gw?^gk}`&@2m@=EQ65B;Bjx|r4du80eewPwbR9Bpx-`_U z3XuV;1B5?g>w(wI*=8Qu$WfY`A>w$%h*9#HC%<{ap%Jl=WBJnKUXm1_XX0e<;eF|D zcyt{MY)rqv&tc`5`3p8THIkdSo)!*I^v~ah&h^ZbKL6n7`5*KE<$}4offvLBlPj*c z(!KQ3OPb^QZTFq;e8+uDA#-29{`Ie$Upaj6efi5zxi88WD84`*dE^ndX3ZL#6LQzx zcWWN(9p-!P#v5;P*IjqL=7n5izUeNz{0euu=EgGDZpOCTxe=pASs4oRsr%-i`N%3? zk~?^Wj3_V9ZYaO~@9UXoD2K?LZ{vC#(5qJOA_M3iy`Op4_^%7Uzx?GdHlOA1fB(Dt z&;R^Sn?K8Z^7r0*&oQR|bIl|9spggZ=tn=Yd5~cCZ~yjh!s;95E9L2@pV9o_FS{q? zx8t$L9&-=Nm+bxbKj0pH^ij<#f82Z{GFRQ_-|Lt#M?N1}(ELL%pnNhvTNu31KYtrK zmmkGvk*VZ-bCt*BobyPp`g%&f7SWSgS>|^H44(PwS8W{w_?I(ZjX8gs1Lo(TYaE_A z=#b`ghI#0OHn)^{shZDf^K8+h%vlj>t(nH;Yc(J39-EguO69`%Oa5Y%uX!+`ESP>a zJn~=MP=5R07kl1Q4MflYuE_%WH*v!bt)VuxK1j-+CrB<$7srg?SmVfWVCN{TSb2kfw0lENOqWSro zp8tY^=a8>tSyDMjwL8?trM)Zp4{adIfz5rF4*ZJNLh!@q&jih}WIkk_=KIt!--@|E zn$uE)e?86L9+ha`2U}e8n*Mpa1zE zEB|()f2=9N-^6>r`<=}dVvgMFufOKL``zza`G5A=XAL*jl02ySajcUWJv!aW7jw8- z*RgQnLdk^uttw6A51&)k=1_X6gFX3U+9aEm|H7iLQSMByh_}XP)px9K!`Dz)H-HXM zKg->E>#e5e%T&&(2jdu!_pf~ADf2~*Og!<#6Q=tgc;Eq>pM0}?L0xyL7Vc&oizqIC!4AD>r0{pnAQ#mteW|Ecx4=J%C#LHyRNc`&IDY%QYt zvdFae0Y;xKJn~3A%epA?pUSV-nauHnw6;Q6 zpi`BHP0oK&Ny!V~ZEG|nQ(?Wu809%o@E65;kMDo~`<74mkDg+#J~|2??g0bJ1bifK zlxyyz-?XOJ=F`&#WNm`{sT+pJFIXsfxK#7b$J_jBS;_-;O^SdSrM zjR-{A!P3TZ|xLR0zMpZuQ>Pj4iuK*xyaIRRQAf!)?Ah|cG{M@&tb1UN z@Bs%LAYJUsek_lM9T-6&|Izx6P0qjG@gi#!cu!dG!FrW4<(ah;Wk$>;Emr>1-AfVw zNo%_NxFNG=Xnhc}%32Wk{j}PD{eK^R_$T)ht*86ui!W-f_I);|le|;+9wcl|I%%2a zzL%>VmTzk(juIy5MA|mUzU5D{=GTUJzr=oxi})|uXWfG8kxkEkY1xZmy$e3opfVA8 z$G7n~t-ly6BLDcU3;ZYF$T#x>`QZyJJ$ zCxA^L)~K+y2BO_58Q5h0>m4t~*W0i^18Z)|!9*d5H7{U;4#dwkzh}SttZZ9(Y6q8UPH8=Jc@0C-zV+5KZ2t=KPhAW@cG-1T z`HKll4HDPTIGRQHq&-$Fr;lK3Qi@XQEnLjv%sWm%VpQ1Jz>mk6L`tJKL zd;U+-x`NT8M#=|sz2)zP7ryRtvNF`>A7gW+cieH7+VRyU2hT=25iGD}6-hprN6q>X zzBxr}+qP4GkM$zxA@Yx(JbVax-uv}aq>+1mZINHw6V@8p`kwLQH<|zBpJ)9$wbn=L zeOPZb!Pfmuu=Omg13};CXx!oDDF38CWArGS+Xw*UKQAX!dxMOYuj(TWlU4Eu7y1VD zfBAtyvC1?$rYgdK`GM?LQK5Aj_!A_bTWz_OuJ!sdDgGjZVJ#DSGI=cCfg$Z^g`1pz z@%}~Dh#&*3MY4P=@3hTnqifBAt+S+VrjPjY%dc2ISgVnxbu`Rb=SThv^3~SKN|*nA zzrK$BM3^tWeEG>XU;M9s{j0@&PHkV-MbbZHjR5o9nLAmbZ%t4-$P2!)J3Bjth37qa zWc?DnKpuU1{Td^`<_KSY6E`{kdf$tzk0I}@mm<#5tW~fwAq>EQIiajGAm5fY z<(qu6o(g$|IOZBWBAvo*)AL_G@kQ3!`1kOxa;=kK%?)d=6e^#r$0GmO8>r{_S=mvVc=9OcAZEbC~{D0#c-%!1mXSjh4ZAko-5|)Tcr57RW+SFzA|M-Cc`W+sd zA3)aKC>_WHYnDtVA|8jl5|UolKl@|+Qpzu6#pdLH;)~?ZWC%G@|J?js(r=Hg`wIC_ z@}K-`oTg24hZi!i7bKUup2Q4WKQ`?UJCZKj*V7edx4s0?f-|FzyN@LzTc$~JllKKlKIw01(@ zp{*A3&zcDAQmjh}zvsX9y!5~~)W-d$wR36fLN7wvJAwTZ2(g>eHo~7o_^~$wx}Wu- ztec@cuoeqF8;COQ*Oi5Ja+Fhlzee#lH~*~bv-c?9RibL~KKL4CUnSP!AZtZxuZ8^A zX^js34{Q(@$$!D-{-_iFA=?Q*%ECD50_;Op{>4+u0fZcAZIBT-K%|*4tWVSNCi7pq z??vp5$W`Dwdum|EV%=I;LxPT^y&BIy`=5{o#za`tV|z$-Y3)ZRdpz{mdKT7Hn7>Q; zyd<15bqk~3Y119Vx_*4AutuKW*Ci9IbFP$3vu+}+XA2lmCXole>v`ta>5)#IZ*u;H z=?knY1Y0X_dcXO3RlC;KJt;&FBX?ueR>z+zKjaku5`?t9v72B+!Y1MS=J+2_zfv?t z>ko?wwFYtQINFlQa_Ng|m3!8Vur|@JuVjs-_Zb_? z1iUBhmd4Gr;RT6Y84S{BvgtYK`yFR9is`DD43K+UFKBBXweArf=>7PQ zSt482$MwTz$~a2+?XuHsvmdjD%J5Y>Y;B}M`rXL9uP42XBFmeUc}3QqFkiIwlF|F9 zbCG-2l6nm4E&skfQfFB8 zA`9f7w0Jp4uJ3(pQ}eHUUupT54q&~h?IReK`+8yR*PUveS~F|aJh5gI`S9zS(GyCu zh_InvYi%3wQS9^6pnO4%T#tx2O)6V0THD#&R4?9YO_|z}ti|f+U~Q+?St;*RoDpR9l;>x`uKPU)u>Dl7Hp}Zql!^vgYVi{sRUoPs$(pw{@%NHNL6*vu?FnJn%lS zS)0n*J>DntYyB!~S;;HcwQfs!WnVDmxl8<3etV~?o}A*> zwX&wQr$=iO)fem%u`ZOdz?wU&8zckhX|T|`&&8XRc|}&6p^<&4l53_ZQzF`Tll4y4 z?6U5ab*_H>tMV&3v}=+BuxJqmEtF671^qc|ViiyKT9wyU-6KqpXFF&AN%@Rs{T=)k zuc7W9tzQ(+yOsAY<+-P~S8W8XVWcmtIsyzj+O>{T-)~E(P9bgL5rl3~+3ncW{1ab! zf6LbUstiyUCi5?y!dlaYh*cQy;a;osR$GVGo+@lndhrKt zq;vM-;GV6g?a*4;Zv2BQkMLS;q7Jp62)kqh=_1rQ6fj^-t;vBfz;+-E@bS1=Kh=9j zHJX2O+TFfhp+04=Qj-B&bL;b-TrV80w+-u|@l^mOVcjjqti26ui1A^-aeD`AZrd#$ z{>j^P4z+js^~CHG#=7Emts{=FE5>Iz>xzB;{o3N73(S9def=iOejKdHwUd8D9VdOd z*6UUD8g&5mpVl1ytv!}3*ji)cL3kwBg-cGj4;DdIz#^givS!!gf-Gq5 ztJdwBpLVV7O|DJWHS*4yWY#8k(S{b0|1L=7pi?r_+1|dEHK$sW`;PMRznUYjx=$)k zdfSs>VCR%C=|8AR`DUFmA#0bDzUR3P@8LNi>#r5k&I{LBvurY;kagIsS0-%Hx$O}r zT!NqaL>n?8c@QS_B|_diB^N#PD^yP8t6*)f%1SqSv9+}|&dbd`Zmg@@x>5OO%{KKD zwmoa#qF*E%*rURF=O)R7?JGjwb-h_3?cG4FJ z_0fSWs7&B9A0KVqUGjM#J-NlSURx*K+uJp3bMrgUbIrB2YUWvTTNFVa!S|RJw5DB{_*^@W_-TdCfZM+#nyp$Qcp+g#aTB_n-s#|2kkW0kaJFX zWZgJx#;2$*C#3D>*O=3uV_iA@9>o`RDR0Cf#PY^d`BYjTx(0?v&8L4v_7N#8=i*r%vXfTQ|j+LrTYCzJ^z^K z9=-Rn?#{NOB^QZxQ#f>G2!SC4h7cG+UlJG%T%B|K&5{8ViR0 z4Iwauzz_mM2n-=GguoC2LkJ8ZFoeJm0)veJ<1-^g_%p(<5&kO1h}ctWoM?PR6BPb4 zDWromk+xD1X$`)ZM(Ox}HP)o(KdB$-%obIOw$b{WxmuHOrq*L!uk~2>Yc1>Nb^awC zKP~#I&Yy#{2JJbW>p0s&JCEP9<7|r?;)js9_8jjp(mV8?%xvO&@^Soq|6CL&y02@` zik{XS(JyOF?Bhzy14{FaO6xhw!#s%*9uBKTIilgBPvfsZYvN`qA6IH!{mY`ih4tlx z!QTLD?l+loV$Z~n`@hS+6Mc(#eTeHDCf55W<3-0>2lC$;S=p~??c=q|<7|Py*}i8! z2F6i7r)v$?L#*jfu4`A?S(B8)+ONwrA2M02%k*nAS)b`CT-(erThGNQ`Hkp(;uUM% zLwZ|+z-bd`^eXs@xk-MrYz`wR7Tdu%Z17UK2WH` zKgnOA>Z&gp?ss&N3w+v5pTuvt+7tJ99iwzczUzYA8}{M} z>kf^QJP5w*;E8;W<)(O$rd;w7bf*&PR?4>@4^`TSsxRKb==@zV0`fY z;qWt3pfyD1Q!s>q7s-(Qpv40y!g-xgar<23Jh~Qm)91c$G(N`SSiJu4iQ^tW7EioB zLoY@6t6pJ!Fg&1~z%%*FSoN`juQssi#lK|>7o}q@e4xFS3-!TXvX2j!b;$2)>V3RK zzFx!=^Jxe_BEkobk?;wAao}Az{^$_?`R9x$;aF)6*9pVD5c+$uZzz7a=gX8YOCJ9e z52CtWJn%dc51KzN9;mIe8o7zL8>Bq)fk%X)%=mZ2 zFpV&d`y}+dSU1k5T^C;{cjB+{Ac?=11$$SLUojtuRv!ohYu_3c(#2jssSGguJE%YD z{S)v_V|^vh178mKrpU@c&_U=O!xjATn;G+?X?}K6zBL1He0=|$)g4Jb;u~-PvTq4{ zyGNMfgXbM}QFJXlZ|BjwIF7zSJi|ZY0etWT5Bz&V`Jg<2bEvC>-XWx}M-HVY&KOki z^)hTEhCe(o63-*-k#7=9-E0egHorU;*&@$;ll&w`lGMLplwe`P-&MBnlFs}Djy zggU<;4amEQAEBHEK3MtVyYR){@4uPkXV72bPh1xz+gR{L_=^|+HpuuF2>U-$?*{DA zKVAn7B>!M1y<)QOu`VtSzC=UF9@&wPucF}FE9Ub~ya@P%so|?@-p{DcjXx3Q@wmhZ z@i!t2vSR6t-s#i6E-&FjsN)E&ZZOQF@(|&#^6@v}k1cgjezE~}S^3iY1MaC1{G}Jg z_oNO&24nam-`$wRU{MZ7WmBR(MO=%P5m6ZDyM(AI+jzFs2Tvu}>x z3lCDbNAj=ldz*-_1K>fBza$TYKkLQ~YptCIdyYLte$9kG=LJ?4isduFV-Nnm9LQ&* zVFg~Ej5o>SfL*{g6)(ik6%Qcr&((8KxaUJ~$0r`|qFvyR&EIjj&hwIeFaMMQ&jUhV z-+26m)e(b*e|C1}V%{Cs_q4x4pFG)5kIJ8ykx&K#_L1zD;5X0Xj2}olR%kv)3%rlb zfQi}j^2o1ngXF8r`-H_WEP3aeLgC?UuL_gL-ajz@z0B7t*Nr$AMehtD-%Rn^;|~7# z!-DX~=H(#bPbg1%r|_}A=@^P%8;s-Vy~BpfU!BOl5giX3;ja-6_s94(_B=>oZ}P7?ETrG_ zN4!v(^kpO}YbGPUOrWo5%ZU${4fuDjeFciic2yX|)Qr@YPKpYo=gRy%yR-EhMV=Ew3H`7QqJXFuz% zyz)wSxqKI2Dj$XyUwn!A?!<@jdFP$)&OYZHcb0tnopHvQ?zGcScc;j&S(Q}YcZZs&d_}+AHd$v0+{^h&BbierdFWlR2ziqxx-;@tW{8{4%1Kz#k$$ZU0^G!M_cD0P*93e;fRO ze(6hJa$k^t>);3cp@$xpzuPtPbA7+<34fQ$+3is|yZPo@RJLw(ci(-F-#`Ao`)!>i zesKf#!T)veee17#-dK4M_Et7Rc~^*USLOTOLBl^k{}#THss}@zBR}%!9bs>MKk8qr zgFPR7za^!Ih7TX^_TFco0KbJtA9IZH1>ZXOm%-oNSoz*FAAs@?G!`F?BK&5R$qxg* z7)n)-K=@R{S8IvdO>qisTl~bDj~3cqw5v>hk!98YItD*{dg6BuKfn0x#cwG-s?jZ` z|B?3~_fb9U>*7=y@bP>(2=Xud9~dnB^{!j{;BWPfbe!ZrSNI#|5%x*jmi6JiE(~d+ zZK*W5!w)~A4-fFW%605mHh!S@viDD%a@FVd#JGV!&WWxO#>tOsa!qwpY#U2^Ze_qusU9c8|CeYpjD-+qI4z6^L>#BtIexOil3q^P|Cua)Nq z2Md4s!MhC^Kqe>yK@VBG3m$+K?O*Gk>lk~M@W+-$8SwT6pAYny@!*KQJeWIou3axy z+VMLR-{dyM+Yo(e52QGcRDb|ic?7pr{&*}J6RiE?GfpOxQqrL`h@TU z*}Cwei&X#L;SM|OaN~*L5BAEpx0MKg>$A{)_U&eCJFCsi^T~Fqz8&eGHG_wL-feu- z@K>5icWCp5@x_2WHe>cH3it;*T*?+fx%WIc;)uC@%E7#O^Na`ZXT&hk@H96vBF^o2 z#IPvz_eTuZ-WZBQIFfrKhD(3Q_x;Go=eSG&tDyVPAJpXpa#d4;>;^2^+1mtL%P z=0)zJ3omfzpLec1_nfocS@OYr#_6ZIQ%_mtR<2m#mMuG3K9x_D5B(+bFTO~=a z63>^Yk550opkUDO*Sqc{jmdh*%0N`EB}0Ya4F19$N@CC03pRiBg>(x3%)uNgR(*;* z4a9>uKL$Gf$3OmIZO{)t{G;vt@vC3`%DwZ>JJ#-`orKS@@2HIwZy(_s^+~mjuD<3P ztK)q+z;CjAh$?UR#lJ@P_TPX1KKPrh#L7Ta4umbT=qaU#Qs+M2;No9+7k=K7_($c} zbW4PPk+8-Ws_h|Qm`Ao}R|A8^>dfNFP ze)yq#|NZyfZ-4t+7qEWw%{S#g>jxfdwe8uDhIS}>(XhwD<4-)UeN`TD4?XmdwJp)@ zx7~J|Tch(m_uSKD0lh)H7>rk~TBUt%u8`06&$x{A(N=D;8Jdk&`1>}M_rDLnk`&bm z^zE&VmVV+`elZ76{tFB527h=Q+HqFLq3f*9WA6s)LH2J@2vIjm?%BJHzB6*p{tV$4 z{9EHoJ(L4{y2pRZmoJliX7}+6W^WvHg%VM*2>A#9`_u>G_xs=fKJqV~Wxf;f zYma^eyS| zUh=h0o71-`Bs0SQ1lc0aJMTQV#~yoF{a`v%X)^s|;}qUL>v8wj!NmK2^!xgNzxj3) zf5fjrlYf<~d+8tfc7QOY93-))EhsX~!QB(smnhw~mxXXK{O`N3FaBzulXv6^zvF=i z^CExV7FqwOJ9&OQzQXZoD<7En=cX?8KHAl1)4BJ}F5mjGgYV0nKOqmbiVEsmPY$&Xg>+|mmqD(Yk&D?{{ifFUVi+( z9_)1hFNn*25b+=F7_N^}J)-$vHVzTZzwyUAE-S^JGo5$+mU&AKz|qf&pG#8 z+h=IkUFX=oB=8&FuqOd+CF+1(ciq*-Ig&U>c!O!+f!~J$f2V#w4rCJ^hG*DUVKDL6 zyYD9-fxo1KK9VQ)E-8^OVQd3_ACIty342?V=pOkTCL1s|7=GxVQu%15?ae!RH+q8o zPE7XnZm>T3=%eE-aI0k#MTRfE^fI$~g6CS@{o=t4Jpr=5*g@ZRtjen-wbN_Nqc+)g{~WcDkt40wWll7ua|TiFuk zN$kNRidTjo>e4ZTCjVo`ls&+{4e*#f%nW~h3*7Pd4S{`f{}gxumC0r!znSP5Z18dY z&z={^KjnaS1O7a>*=8HJ<(6BTzxz-Qun(>8M;i%VV)MjL@i)KuqHJ(an_U|p!=4A@ zq-RRZpY=&6FOyCB47bA$JBr_GBPc(#=_n_M9e%i5ykv=+HEWjo@4ih$K9Oa^l`z8I zJ;EL$hJRXL{IRoP zyFvvqJV-wCk8?9;&a`g_T?785l4r1oY(E&q z54z2Gk;KIFgt}>P@h@G2yvFgjbm_ZgN|WuM!yYoii~SA49uCxvq?3GNS7)z7ezfJ2 z_$&WlOxrO0Xj_6m`v9=l3HOf`Hu2vnr<`hfg>e&n^i@^Wm>u)7%PyCH@=I(#5%fOz zXFOp47381m`1qy_?z7LnZtmP8-HaJC)Lv5?0$B!c$++!9!?EEm>=nW@ zZ9CC=;YOAfSU2Az%{=n3* zRi4qoUhc!5K>i-rjf73u8%N=w;V&MqL6-Rj?+@}oh@J^@&ptYkVISeoJ}U5S_=r(S z`3HY6#r}m|fB3LrE{=b*d<@dBV{e*rwKw4b`$Y^>Jla9=-*Lyy*YhLvz6sXH+GXcm z+-|$hF~5tX7k}&Uy1aan+V*={-)lkK>b5si@aI>ln>(p z`Bz@Oj0*$d5b_!D2b+LDGAo%Gl%LwuYuE#Ze1vZV?9n&L{fmVC_#~-)E{xghiS#kP z{lEhc8g%HN2@|w0g>($%XQb?s*Ib>F|7PhQ>_`(QPO$wk-~srvR{^$6+KTZX=Ob0V z*dvNPQ5navJxp}YzDeLTN$sDV)b80sZU4^Bu1N2weQNq;tkt{NC8kcDYV8H`8Sq8+ zAku8d;GjHl&OR-H7yh2#8){JT7cbd+#^dkzjk2^UjqC-0oU(@l`zV1wSdoAFNj`n! zgnxPp|KGoF`v_o{FO&SIj~?l+mFd%7( z#~*L|t5#?q1@=&ZDug}wv&Rr2I%)Ro+44u-X84mI$|hKEyY05t_V4NGv36b(QXozE2Zzi0fr_@bpiqbQ$HF^wQ_#8-AZ4^+&-wBIMMQ-$RFVOn`5CX7uO` z_ki{t<%j;6r0dv_&|Mj$!2fEy2mbO+Y2z!Cw6724;27=W^QzjH>{T#L@=1L^S~hwx zkN-GNS0BRfC*$|EVb3p!{UoMMohJRhmHB`*S&|F~{JFlv4ztYnD|IjU2Rsd9%ChC1 zG7bLP!%HD$AnXstePqt?A2)dTD;=hP-~qUUKRAcIvrJD%x|-{gw5Lwkvxs^R9mHOO zjDzzt{DlcRo;|VBM~&!%e?mUhOJ&EX(B4e!uXTxR|KT@n>J-^0N2%}U+f9e74vznf z2jXXy_R?Ta8+ZZ!RkFpk%O`J7cenh{CX#x}^p*01-r08B?c|@m-P%6rSIetmoyvE> zmpp+7n8PRA3!1nh>MLP1X!>8WV*1D9ukY%c=$It_l2gC8u;0Us?@v;`!Jc}Lv}R;x z*`DNJ74XMCG(r7^jL{?1{=e2>^zz@NG52C&SFSPNOxeZGNkV+^!3UaO+L<%A)gE@+ z*q$wn*TjDd7aZ?0wO1PZ%dn@9-`A+p_WZ!VFTVT3zGv*CO)kDP67a z%GRRLY`>NE3_7Sk{^)6Bn0*(sGDf@WuS?YGg!oR9=qQ=@YZQLu4Z~tTF12H!! z{#(3gp~_CCl>yspOnhMP4EAhc4+-=i<%0ceke7}Q`7fT*V|zY;J@uFA^5{PK0{00M zQv0#RdBL7kkjb7xJwIsp^Dgi=U8nSrHV8e#{%wZ49jly6=1f+o|MU!d*%|(tZ;;eK zYX4NIe1JcDcCd$G9RJRagzDT9;Z>pbX_4Du`|aY;ZuIeD*)%;5v|on&jH8dxLlAn2 z5FQ{4_`;@au&+a?-^ioKoif6na9;im2i-GSj4&r&z@KB4sX@bk+_(vAXurYRB=$N+ z=Tr!H@&yn4K6>m^1@?X)J@6l=HaNCP#-8}0e~3f>jk+T{E5qG*gSS7Re|ow*q#w#` zPZah=89R22?J>ys?}eiCGzNVB`R66+f(y=f&{=1lp?#(weHO zYI{H5fWP2pbsPH^f_H>J$A0_?o`?rZOSx<>*xJILoQA*33APJ#SdMi3_1Am*1Nh_L ze7yFzsnh;0?D^6l-}qzIzh*v*w|A#(+}_rNZNS?Cv$8WZma;U!Zy~mQ<^;h5_E><} z0}>ukM<7G!B=%!qPbSJQ<$`)4sZS#O!J7R0eE@yPG4brW!ok-6;_;eF;SZjC%j8=! zLz^G{LmM60_r#uZ+FPu;#&i$QVS5Mv2ctbv>3^~J5O&%My+?Dk`{3W*%|4)I>d$H) zFwtb~pTiza9M|jE#va9!dQoGO->b;>r%}IweQcP!zjSF|9>5dkW3k^6_C4?p`iQ;) zys$A$rHlNTUea~s&C9h9(F4>2=pw^Iq1O!xkxS(r9;)07n*LE6W)1t*g>-nCKyUiJ z8z~1+wf0p)|FdTtd%9?^E9s$XtAjIx!p7J8+pgL7o_VTZZ)L!EK>LXL zK>PY3H&ObL53Z3{$jh|Kh3R3BIeZBG2-px09uF%1laPPvQuGXZ$LpU8$^y8vcc#)^ zt33q4H4ytKvezPYKz2@^^pDs7$UiuNe}%AO&Cbm?uQuMGe`an!Q~QKyABJx2g`hSG z`vdj#YG0%&Q{0r^UfUmN%G9aaS8%GEy2Tc5+Vtsei!Ga$8K_QhO>*HGVMH zJ^q6Sp&a-<#r)oK@ir1=0rL6tc?Mr}a0NP5`S-{E9_QRAgl~qwboQVafAkW)dyU62 z^&RvE1pe&dhwOttWzX;z?%)sK&_Ap>VJ0|Ce_`!qq(?9IqKmOsv z^~K{pbJeM9IFA3Q5Af?iIiS5mSqSxW*aOs;1;0ll`JrqD9w76iJJRz>c_SXzz(D<< zLBrqIzp9t){Ys0;LZoX<_ThVK4^{Zj-fA9y>4S!#H=+c9CF8>3_HL$|PgM(^<9tvbF{_4^H)=YMw} zmcj$(PJ%r)??#mc+xt`Lp=@}amha|0A#HZ2O++Ii*KD zpnY!p0!D2`_NJp95oE#dHCioOb3tGIBmA-9nyyveNH^nVjC(N_&$u}ALm0zl{1z%= z{8?iqMY7o!YHX#L#9#$R>LeRWC`_|p!eeP(SO zoqHb8zhwVfzn3TYQ@1f5MHxWGnKS)xvi>WnC~WA=(`wCmiP+@QT8TM`yduGhYqjegIx28B>lllDkcSSY?OUDB^@x|DK| zYx;+=2lNno!$ROs9z%H`-^K@Yk>n70jPSN&v=96k8DW)nCz?VV|!TBUs8Il zZHFAG9I(eBbLQ^U9N6$X?9fAP+`!^0KKq-Zd(i{f$k+pz{wT-n@z&hT9(aCFyp|U2 zeb^+q)}C-2hduHLA+E8IY2hq!%)`;ZT*qneWU4rlRK}g(G|X2Mm7TVOW6~Z zy_dKEYA+W-ANlg_^@1IXAIO~Q-+Km2 z{|NtUO_xiKDc9jUv=1l))`ny6LTmw)TiOvy1EJXps0&mU81FhybJjksznfRzB)jwk z?E~17+-pyJU3%;-U%p-rGKEb9T_5V-I_Zd@|D~7i7%coNs;Vxnk^ZC(@Ovivvd6c~ zE`#omY%|!(u*n4dfes0T%rH-V@4fbN_uO;0d*X4;(SPJ~qDR~#4?pZa_qm7NBU*>c zy#2=>d(fuF;P=p0TR`$Ap4>24 z_^S>(eKLD?(-skrLz^A^{WrCbDfTew9>YDdiE$kEsQ1UDyVk~hSxZ{RyhY8E9*gZ# z_8udRJ7W6}_Xzzlb4&dkJDX=}*ER1>*Vr4Jc@!Ko50x>^32MuZm(3Jn|8K^5&@Hr= zt7LOxY=Q9|hLZ??}}h?5o+LvVcxPo~*wnJxjR`d!k!?ApH;i z(ixWw7XGrM9mig{z70hCg7&WJo~WJAzS-C&y?s)9s4Jb=BYl0TW7V6@x<1 z=2Niexb~(rjJve|Huy5m!#pUOFU1*|(n$T<6-cL{Ub>%GTg-X*LVe}uY)KcVqK*He3d>mBXdLl^n7vK`8S zuivb{EFQoQ>Hqx)3xAD=)>o?yP5;N&0n`JY&_R>~D+Ay!TqY}RVIO(Fhdg~C=}pNF zc1`1p;+akohU^dDB826^UZ`#@< z_Zm|+{E>U%kIZ)q`>E5kudrx}?5p6;p6qt4ut#Kj@mjgi{>%yZr#+q}OJVO{%0M9S z=a_b%aOoT@{L^O47+F_^T1-OQ5( ze)3AZ%*zfVL3F;BN%)l@{B#p8l!g*Q?$Jdx$;i;R*ZH z!x#2vhab&qTfmbhh0V%SC?i~tA2*8!3E|wKy0I0!4RhpNeO&3~gx=8tzoK(^N8C2m zIou~~*Lepz9TK)Z!n>AC5(o11k%%)3&3FC)S>R z|0Ar}J7&;-YG0k&hz>HmWz(`U0QTO_Lx}Ce>|C;a1|EPpx(nP5Z^}Wl%0a{jctKpN zBck#V_+cS@imrnx?*RK&@dqB+x$c8MxVCDZq32z@9dpigy9Z{%JWy+U;Dc}$=Ix#E z2R`(QZwf^n@JG*diO@lYKfI2W0p!0!n6X~B>5JP*HYj6KA?Dj z{ui0;K|H`-L0PF+c|ae5FFZge!H*RA^S~QIbQbrKcVBNu^@w-|mcE?$d&F-SCJD)9 zYr^AgJkfQ-9elxCak$>rA$jlYb}jHo1pWzK>*$_hxOa3(7wH%Z_;;&JfIsur(LdPm z-~oLE+M|h<=Cxoio7Z2}R?Hdzw?Vt4GO#Te8{RcFYq42kyQE)>y&QW4Wgzr>(M9k9 z`#V@RhzIb(%7*SC*S;+HkT@pm$h!!BSvf(Uag6K>Tb^lYmHg|RkZa)0{Y0C}0l11M z#+T^4S@BwR44)FZ-`3Hs_|yrKZ^=2b@2?AI;omc5s`S6gf%wy<`@Pf);$ye;7laP# z>}X%xDIWBwK9P-f&Y%JP5igRSITgE9J$Tntt+l?Wco292Kde1KA4P3U%1f{jw@}BT zYpA#2gYJb8o|ruu?7>$dcI04FP6*#N;fGy0#BCL(3E>GJ;FrY_?&xgdg0;V=b|d`Y zd7eq$Q<-R2`4A=A;EBqD_|YC=&U-q9xyN1gwd(9{$$yXVpE{*yEoESu+EME3UHlP{ z4f-wh^{;ACzo(Hle{GF;pniXSt=a>$1+Y~K`>2md+n9Em^;;tR8>N@vO9Z}XR&gTL`nvM=1>1w5ksptk~V;0e5<+;kfL z(p!38m++q={R8ejI!5lNz=N*NwZh*`pElL?c6Hr0=m2lT3uR|H-E1rB6E$J0X>M5C zBs&efz=nW*z}jWv2lkWDXY}m^@K;|-xYCB>nzf~)Yk^lSx=tL67sUz3)()pW73N%{ zZwi07j-IC9z&&`$b-Newhhz8>_(VBtqkQQ;xYN#IPCB|C%)3f;k{(>j; zSKtvmVEh|9qqVEST@>1PTnA%9yGA_W9r{B&&#_&L;#quM<2uI>^`_^AKW9xwx8#{V zZFoM|8L&Nc2y<{p&!FR{^mMtcrcZNId#C(KHl#iJlCG&sA-qqOw z4&Vq@;3yjmq4 zo=@VT;=)s~hqw;bv|a3d3hlo5$u;`6guIh)cFRt|x*KrsozneZQ>S)c)YaAXv9@zI zhmW%PWwbQaA1u5d6@I^i7ZA3vKxXeXyKiLs#V(0`Mz#WEi~Ag7yTtyE%ylcAf=yDk z;a8Fw)kZ#59jLmQq|E{$qsytaJWvgeqAEb zbiL&JhSt`m8{68NZxrq~i6^Ud?`B=UMRcodmbdBJ?Rv)@3h!udZ!uDwJD_k~HqP5S z+uCkxYiYSv<>nTRCEl!itJ_!^jXaXq|6hL?q2Xt? zs6bRADie(tm5U~cDnyl{s)(u;{wq>QKY1Zfw{ zj~qSvH061L@{TWwnWA}xJ?n&gKYBmukWYD|WV)jJ>pVxgxVJ8mr&PXl zoSSFVH*SuT|L6~e#p~&rSr17r@a=*;)QHB2(2@VDa)6F664i_tJ^G-moSa96$A`JX z0ZQT{-Wg8tFe}^k&J-RS2(xrQ&_~40j@{!pe!iY>AX~%>p*_>@+}|VK=22f6h-62Y za9!{GtMtW_(ho-nyUC(r5xVnV=|_DwPWo*R$@eq4d4AvI{=CmodBR_FvOJ3?*@m@3 zj*~nJybfXfUW~`d_?vJoE)yKbX}#|g-?Jdn8}tUU!1F;SQtujAUX0g1zUR9g$7Lfk zE9;w49Wg<8g6XGS7tpTF71d{EW?h5M!#_3r54?|;ead~VC}4;zcuMgC-(EKQ%TG#%h!gQ7;;WAf|KOvipnHsG{o;9kr{el_bFOzp`3UmkuX$NW)h#Jm zPsvF?c~P0*UZ{UK9-WcJI^0$f{x9f1d>Vg&_tTZ%@3L1kzOmzc?|bZd5C1*oBt!h@ z#|hrV&p8hK^7m3a^!lXl{l0Pg-rG=o|7?nH@D;t~c^|$Tu7!8&daSLGtlx~U(X)Nt z5oBQCkUb96X8PgCkt4Sk<0)UCHW@%KWhs5LmDl(3^7BJ`XRYx(&)50=`Jb1Y>jLEp zk6fKw7~^+tPPY6Cugm}b@-WbOobO&v27b@F@z6u5xc%?jyOdrp2cGAqx1wvIUh}#v zE-O)+FfIq-Smj_{oq!zWt~Gt&@Bc;h!QR4}b|K~M)ACCf?{^R`f5yjp4tyMt=c*5= z6O!clpPxra`S+Bk5Q6ty_xHRU1bMI!*$~Bf#WC;cf9<2rLww_FjPD=yOyGSVzDK$t z@O_<7=?Z$IuMUZoduvzdoDkX2GlZE+mv0|tuayk!DaL;>qePGR?3$~Wmn-aj)NR24}G3De7x=mdLqb#@jKE7v?IdxOl!B89GEUpJLE4a zZ(9oYPeVOWmYJ3LE%+VkyukmY3`9JK@5X!85Bd4(*#=bzwzr_Blf191+%YVW=w{!bMBGk?P~GBPeC%|5;8DDd(1Tz~$DGM^9sqw>$* zZ}8iA9?640kK`av{1^HAzAivEybOf;Atn?3u!na+1_%S62jqWTMmS#QUD5N(V^Suf zI6)5Vdh$8X-%$Shgo@*3A*Cz43?K(7g5N;~2+;{aHiSLCuo++Yr~F2xjhafoi}Wf@ zYfWFlf9V3x`=}idEB{vR;kjhM6UTY3FiE+|WA;eyLnbF)9*_x7swZ>|g?5DTnQOv0 z=$OF!bwa&!oojk7buD!tc}QI!_}FAd@zx*L*J~yVQM|ZZ&}INnAEw$1K@OtOuqF=e zu73*iHBOhAnfaLKy_W%#+fe>#0|x5D|G@VsED--ig(CJQFD!ukc^{b|p2>}uiG0b1 zQKU2S;G-Q!a$x5X{}ngL$U6LsWG6gtq0-$)mf(9b9`78O_kG?S#gD%yDT^V^f&YZ2 zQ+$YC0K1e7sNACmDEpBzPv$IotPwIcs31lV63n4OqT=@5TnFze(S~w=`f1bJrf4%$+953AWa=h9yOp6x7N`qU7bWdovAV(ZgyN1!AFwsk4lxznmKe>iUX#06y6<9gXx4qg6;eA9!MFpk@*u%Y8$Kkq#JY#a=Wgy4` zeD%k^U6~i>JG=@+n39iTdR%_q$NlB}$Z-%|c#^}q2xv`zZ(-(IKen`+sCND`D31u5u@!#kx zPhN&neD8Z6`0V8&#(Q)^bY0XR!Li}*8&#^rhWXgxhctM6j$h(Y>v3KctFK=G%I1c*B>wV-Q#e3tw zy#srR=Y7x*Nj)G;lX?KYM-u5&a$Gebfek_ksKvR=;*YgcCfm zvP$_C58)~NEP~e}czR<|uCbS~!l&j(?u`Q9kWipI6gUT&LY)Z5L#KcwQDlXnILHlMp>% z6!9PV2r?1siKq^^>67Arc4p>R;lJsC7~cc`lXfi9j17={MC9{}UWHHa-}4ea7egh* zKD2X=MY?8k5MATAP%^=@p8x(h@YM6(%L07&yo=5U;AQ-|ycqBOb19km=zFnv`bJz1 zyj+qFPo}$+cg{oIWpN@|NY+o1i?|FV`ED{1lLMWLAKwxGKZ!8`HAS=D4QZhcO7T5) z9O{7nwg9h30}kSS;J=k|&u8N~e2-C)jv=lQdj1!Q|0&9k$%FAO$xF(1iu&+d*Zp~+ z#aV~vedL9>#2bj$ef38?PWT>t4>FJpjpvaZARqQUyMkz>h6VWwavzm{E6>vFf!D_S682^m6_=>oi%Q0b zIEVNCI($#+0P&_E#($3O9KIRfeY+reyv}v>68!h^L%bj#HkZYC6XUhw=js}ijMwkj zzZ3bPd2cM%kXdX}WYeewM?umznwXRdHlgAN%`^DjYj>X}*Ks>vy9FQ(Am&lfH^Ykh6 z5&x|%WAclPcv%f{;PVp6f{m+s9{9F`>`7`*W@l%9?0o>f5%}-Ndm|km$~$9IIz|`3 zf2$`VorSz0HgaqyKOf68c>(gTsMa7;LS!1H0lN4WHiOfQ%H z`03lN%6H&>lz-26e?7>s;gpn9FSh|luTy+mL0BL6u@-*4T;>00tCN!Dzc2p-?|ofB zI{+Is{Q%mizU~U`8lQIhMn1pj0m?f1gy)YtZoa$h^2^uh)CS!cO3&N#!Je){R|w9`bVp5{(HU z@)d5`vgPjNlb5-ZPCD70c;ZR!gcDA1OP4NnOO`Bgixw|-3l}YN3l}bO#~;7I&7Xg~ zegD{Fk8{TybF4f1=wsZxqmB|C?dBdi&mD2Zk)pZo@Wbc2Lk~OL9dhVl5gn@VPyivE2?!bX=ZwI=*gjb@=Q;s>6xBk2%?ByF~_UdaD7+3UiFz! ziGpy|X9N?7m_>p_MTv?i0!mOp1r-rR6p<{72~p~O|FvrEUFY=a8LrnE1<(2YR#)xX zwQKKPYkl8Z70&60ZMVI+LOKc?$T2JL%ezM!y7zkO6V-Q~z#qBqa2}{*L&v`#%O>{S z+WMCcUuFE*pnr6W9k?y{Iz{;Zn6s+KE9Em|+xCvx2JjI(jTw{9J@35#)xVSe%U}MI z?z`{4^v6H`G5!AczfZsY?QhesfBkFw|KE4qaYwrCw%gJzx7?C`@rz%ipZ)A->8JXi z-9P^EkJEM6{U}{~?GMurzW@Dn%{AAg?|kPw=_>vI?r(kTTj`1`zL74w?DF*WuV0!j zx#Vl4t=__CPO8U|T7o_vQ^riI0FMctdcitD$IcJ}f&N}PtbnK@-Wp;oK zm~Vjd0`sy1^9^PX@O{>~&IdP=Rz3wEWa4ol_@VFUPg3dTjays)iuqpW{*k!}|4u`- z-k7hhuf{&ML1{PWWnzVL-~?z!itv(G`_XX$@npQ-hY_OWAd?VVOr)1IKntVKEOD_xBxzIq3>)i zWb))UZ*BccfBU;lW3h2d|1 z``d=$Z+zp5bou3%S314uqKndn(&q)j@0Ut_o_)4-`FZ{C^E34S(?63=J@vG7$|z#OC{64V)y5kf2|B3XwaM7Z) zop^Ni>^at#^ylm>vu4iH|5KlpNN3HQssF!@@c-9mxM&WMe5b~ko$4zS<`aoIbYIfQ z)0d^7Bh!;%GJGq?5wn*^?zyM-YwTot41VXG z8N0ZzhZh>f+jjg{b?B>X1CfG%*}}WFw*JNA2fO}#jD$Zj8<-TScz}2hkb zee7c&OP@UU*z~lgKRs=)F#)8)09y{;`?h1Gfe2Rr)`CYwKS+ zJD6v5{O2*VbZ>D<*nqeozSCj)k#1b)by}^5+S-`b^=(bhd64lRI-Waso^0SI(|yj`k7HrHW-N0kSN$+^)I}QK%fu}z8X|nAd>o(M8*g?fNdQRrLVqPoe zI?Z3GUG#2q=Eg}JyYKH*pDDi$^gs0wo&}o>8>l(JF`j?Q(HaewW6Y~Yq zJ+`4|F@F#?V5$0ECU>wtqx7Z64`?gpEwsrinPtZZ|fOP$H;hs;)1NvX|#uFA_YCY2Ued2iS5f{%R z#`hgcU2LCt&QoUmj}MSc7(Y&&kC1m6Pg?i_TkjO7;|CCYo9n;k1?<4|-y!`gUSqD} z10VQ6`rrpYn4a*2C!`(Z7pN1s1^>3DWqL;69{>ATpq$@yu4wcD^?eOLQ>Siq{maG< zWe(PSlHSi#_eE3SKlx%PrP zevfS6ed%$Jdz|SX{D=Ns_qERp-{&y^_K}6Ypx@CyW#~Qk59REU*hc#Q@32F+MRb<& z53fns+eGU7w|&31>0LUX4(3Dmo-bitzFWVKV!B4(2)dtvwBUgl8yYFJ`19vK z|2h5XPk*xcKjwn&zWZ*Q2fF2!TQz@hQ-VL3^ZTLZ3z}`=>tAQS=VG7RlPxgMb56De z<^-^VQ%^fJoq6V&=}AwMqF+TG)<4nd%AKEvexf+R5I0zp^UccF8dF^+u z^5LKM*kh0MDXr}=wmbarBNUq~F#HEDiHqnb)*0XY<~N({wbx$OF1+WWv8L+yo=5is zVLx1J=Y(_e#<{Ws=K$9^WnKsS*hc!FGVT9ii{^_oCN(?27EGj1@gQSo#?;KEblP~j z#(n3EsW$eeobk8q^_4Bao2LJFyd%_a{A~EwTncv1eX#-bKTGSX+ikyHVhq?kF5tL( zakq3f#?Ip};EVb5N#~i}P=?G!G{?X^19{TfYMb_1yR}sJn;CJ4QvHrw+UwDew)hY1 zvp&6G!NLmjJS$^B@WJ}>A%`5IIPlQ)DDeiiO*Er12|A2zW44vjnz^8@(x#fujko)<1!m=n5p#zq*M2Vlgaa4?7jASw2kSHP;CF^x4cERHB$fd%PU{`N{a!A1NYSYKfFSD z;1=Gu{`^H{3^ZnTd@PqWfU90xc|EyWFwz~c`5A`|H9{ zOSgvc>6)L_+^go{kWT5^^Smp^=#_WO`ftKN`e$E(^?~k*4Ve8YaqP-8SeNtV`FX@2qp$Lgy}8Thjb9=fe&Hf^uG7MKfU7} z?@Wgtc39TGFb^*EobW%-$zGEE_S-KVaKHiSk&k?&tsl@&yt4`Kq-*HEnX~jx*~a}H z-R2?|F#o4E(LMHqNaxlMq}#&(>p9^O8@QG*7w_||(_H7X;3?UG z(pmXk;~lUk@c)h`{dacGvbZWRV12;*tDoR$bOvWLuimA(8g$O5v@>sg$Wvy zqdV3Y(NFBxC04UFY}T?Q%xl9r>>Hdj$8ErP!1!Q}+L@g8c*G;qyAD6x_8=XoJx24u zzxDwJMwu6bquD#O&pxl!o}<^NM?B&YdY1{VyUhJ1enY3Bd*0Q!NAF9Y&s1O7IhAvr zai>!Hj=s?KTOI$Ji+(3~;GS-e*kSF1T<5Z7;d-`oJR4nS>|5S+pY^Zvjsy4W?5ShD zBtG!}%$c)oF5Uc{`jdOn7dGE69kP!eTbMg{sr?EpJXg-3;x%$zYE@sXF@Ls?_}%ldKPq#&JF8I(fgb^dRC>gXY;P;-)&&_ z9Q>=}8U4S#3IDUy-(b=4PG7jKh5q4EcoY2-&k>(9U)#(BVN;Q^Pl)-Tn{WQ5#{NG} zS6_W~y8MbOY){wu=YL6aJ?Ez5jz1yw^$%*lmVB6aVAgDW!Cce-F1zinnC`H&{{aVT zoVkx;i$#iI)DO}XdY`J;7~TE*3tpgc=gZ9oc9Z^D^A60@XW*9jA3YLd&=+9?*2j8Z z_Bo(;!<)75{T+S6wZyP$Z>!^9`Z(PEVdf-zK92{8XG2WCI!5=Vf9c(A0N$RZJh8P> zJ-5BP+HwA`@qg>Bx2f(Ni>t9Q>>d7Gx^$_{*RUsuxty20Q73*?{oyyvHv{1>f6kmaTV4Oy7&^1()qCAw5!n-~Yo znLEdLfH5KC`o-8ljt4^LT)^BO`}5Ay9W;r3aYM|!sA zm;OU@O)pBDHf=J$#d{zPeJ3o0-q9z*ySvV@j~Z{@-`;m_Bf`J<58VhuTlIsFLI1%~ z7MFzn#lyZILs-}QfP2^fEZOHA>3>d|ujibr)a=!4z~gPWk3F)b|7ZSZw)occPk+hY z*jKc2H3Dd-VbCsfdQg|QTBj~6U;`gv~_Vy8f#RvRzAL4VviuA|7%|J>a>&e zkKVB>#(J#JGA3jn&XOfd)6KtdUl8L0)_%GdYiy`}dd%I-*ZduE4SSAW@rqaKI|BzP zuG?2@wofoynJ4VK{^!YNW~Q}k*V+F6=Rf}i={e7Rp5_%6Svh*bW{F!7_5d=Uj13GA zZ(xjO?-FqjeJ9>FUAiqumwJxSyN^HJRw#F!dmZ|QYZ2agTkyZ0QTitqa(-%jc0G@k z!};jm^$+&v&nG@}><9kw)iM4CD|}z3S^wAu>q_kL!Uovuu}bUwtmVXKpM74F#%f({ zf#!Fx0XTsCLe2HCR!__T58QOq$g$z_oGB@r-9Y!)##v`t>$Pf)437;vchr z^q(cTpZ+l2s~o+Dg#Jt2Q?@|QwXOJ{d6ZiJZj4@c_KBY}vBP7X%LwAGi%n z)Si#Usatl?v$`ks_4cLyzJBvH^Yje!=4s9_3--|0H(=ZjzMuG{Cz)Nq{ic8E3!g$C zJ@&DCrzdOe=$X%aW?CoxGtV8EL2vW)tf6!IgT5_ofjV>59`|(EQsr0FK>$DYM>k4;Js8IxeYQM^y*>b<}e`e)xSem6e&|M@!4egbrl z|6o5^&+1hT_Yuu zJ=x}r!IJ4*?+BMH()%tHFTfF;W6ycN^#8;sJjwjSW3^@tH!xO3oc}#`XPtmG!zal` z2G_0?M=%z4KWH%!dG(E9L3)ONtZ&Uv$`*KU`iQtj=d91OozRZihmN>rlo#e9XVIZpM4Tb=&VSQvc|Iu|E7iN5YnJs2p=KdynUUY5xSL%F>UAu3#H5l14+>GuSQ_*+C z)jMe)Rf+%GZUg`GY)*Nu`f0x2leOgjCjIZX@9WZpjM||>Kh!}N>?1(;2z$x;){NBugbvO9FPbm?$WJSsJZZeXfA{ICE%y20515PU zk_~ouEwp{!E0!wnV3j&$M)$43>L;BxE=jB;^zD3U@U4bvK&p510T-zmn((_gCifu1qJSE+eCl1j6 z`)t5J<3IY6^+ek2Qd{VMpl?ls|40ARrf8l|*k39RK==;O$3OnDN~f0D!k@r?X#>QB zeX;>;khlT8;|~zxLu>$C!wKlyM0yJS4=Cnet8w4Z&|0h8h!yvVp+n-w+LF0*CyjgMVq{t_Di|4Y<&W&@O~@47VK zf(;H$;an5>7VP+KemMa`R?Z6z)1Y#@8SNXOBR{_*>m*p-FJzm{;IT$2m1PY z?K#l*O3gpQ1Na2KCuFv*_rV6jKhO_IpSYpFuTRfBVAlla({Eq~+@rhj_s)?S|MV|B zpt_{g<2=`yuQ1Fx_Bh@a`d5r%^FQu`+|HR3VO$h3KDwe`=pVBI!#>}k^1ed%=p6l% zUm)zWe}?~g^zMTH4gY#a)(nZiXqP=@>({Mq;Qs>;*iYq(*X76kJ6+vM7i-R7M%v@y zyQNQv2Ts#|58}a3fBHmA$(P3`Yt8rMlTWg;V~_phh`nUY5A@3h*x$5DaY5*xF#n-q0L1|vrvLX;SjGR- zH^j>pKY)M5@Ph+=4g7z={`)CTn{4`DCcUG3>|nLN2guq3-$5jfn>jP@O;a1T4^iur z>`|Gkb!o;0>{*ySyHk5KCW^BMN6Zfp5A@0xunV)_52y0>#T&xek{b{FG1 z{ItdT#Nvy@i}H!WzjFYX*LQ3cJK6i$dtwX3h23eK_EnehKm5;ov9AOD!yRCBaB!eO z{|6m(pw`=`%C3Za=^H&SU$M;nG{-Al?<=Ku^Zb@Q$?moPLcdEQJD4v2PJV{QY||0>YZuQ`?xKB4ipOTLS4IAIj>ed? zbv&CnYu!(4op#LkeR$vRe)qffXfaMSU!XaC_9egrVFN2{YyjSU&WLh!Pdsen#%v3r ze{8_w8|j|DVZRD`hGP-u2x1E73;LTfj;XsO^J&*M82@XZ!x3QDe35i-{s=#Aw&3v} z`bYTv1m}L&f2Dh~0rk(a<;DTs@+S+`hR6Sn_{Y{*FXLUYA@j=`OY2*|VJ7MH(@(eG z?mSR_VE+RSNTdglKkz{1531x1`VP*4Dm&;6Z%79pa)|vl=s^d2p5r%AraIJPUpL2Z zIQS6d-)O%FTJB8_8}M;KzwrPz;C7(+Lps3@h!5Njm^X0Dhi?Eo@PYF}t!HeWzCy?7 zH}p<@@|+`&P=|dD_<;Efw+;XEOg?V+`*-OX;(5R*0{)4O!|xM!(GT{%*tl#6LBHk$ z6a!40I61xNy^Z+Cw$VTK#yyxbBZlC&Jo)_j&!d0myE2f!VTL(@m>&uoa31iveY1fa z^E(eLsbWN5Phwo`TxeWCpJbh)+b-feVICfU`vd2pbJDPZGDZLDpRMoz#sBD^m<@jw zc8bm|?lpUc`vd!=ypR933TyA^)WvOpwwA~b@Ex3H|BsC@XQFpAA=aHZX`+7r_bq=z zt^V~*l{UZ}&m8RyS#2?)VhPy+V+8nrrG)VzybyeV-XlIFO8+2>;wbWvptZVcA!~oJe^D_MIj>X5~gm`xB2;UF?Gp3xN zeR%wqLVSq-XdnMif0SIv_r>3$@71&a9~}@6{t3~(K=O91{GaYayqt3*&--<8O;D$9`)e?aJ5b1|BU~LO^lDE8*~cJ@%#9>&^y1W#9YiujmPi@76a*-m&x~|TlU^9 zT}pnL_W4d}i2r>2x5U;w(Z9W~_+YW_Lm%+nAI1{vhlkV9`t?dTqzyaooYrsHXtHtV zT`b+O^QN@RuDd6WhqY$EVbdoUv^9jU%S@*&9^a1C~WAfO6Y+_sB|L|qYR_GbPuE(nOjCxMyS^RgjeEzNatu0|6 z-LsDBHc)dH?Vx*7em|v*{|*20G3N92j>eay^nrwwxi8x@rMQ5-67zJ-y2KpTNwv0Y zYt@RS=WBkk%i_$%itW48ynH-=p~g|lFIc3xv@ZEv$r8OEb&*Bg;;ZE=l}abrse~~L zu`BmozixfYvEe$|z#Pq6VFP`PElu|te+sAA1nY_HVJ&CW9{kBSY<2x>-S6$o<>}Z+NW>4AH1Jzdub`v&){n%WK`ip+U{ynZ>?oaKiy_M4IGGTWGI@j?E*@o#{=U1&> zW3o(n>;fBDxoVB{t3BU3hyGXTnEKRRBfYPd-qHOUjrq}i@0woud+As)d%SxMF^S*umy0;BeU!@djfK<{cwt z{Q+61GW-!SF0mvmox=?t4?1UJ1J<9J7r>T@j`8)5HTR*Rf3uUNOSk&(|0+&DaJeuQ@gL7a zp63Ps=$F_Z+^;ZwW6O&z{v!q?27m|P1Ktz6C1sq%9`F73+t2zh{=8K&KKuU5_yN3F zETQ{>AA~+Mj!`@41b$cpR;3$!!YV|*fPK4Q7#&g$r?W4YbE|Z2jWE4hDbk}n$A)>O zd?$pxKCJ%`=llM@L9GR>HNi$VZQP*$NpPb*evA#-YXsiWH~Wbq&puXre)R?K$9U0v zg!lo!5dA{m1`psD%okdJ*F3=cc6D{VQ}du(^|PqEdp|G`z8@Y!-~o^SWC!8_kN2Ge zR6)?g5OKfSAukyi-gzF#VIAL-rV0erz7cifTg(3mh%;zPuab*?0bIbn}i2^ypOK&0eyY4dDdDpRxMR51P{d88SjMfyP*eZj`qR+DRs}g{QL8t z>-tw;tXr!+VTudj0&D^OBNih{r*Iqn5_3nyIp`lB5q^NahC4Zq_)q$O)7I9%##^sI z_rw6`AKfz^h-ZoTjBx;ZUTwDR>)##&!_z)5W?V;1Z123hvUk3(^9Ie|KU6*!UicNo zcuNfL>l6#He~~x<8!!&Y^8|r?`i;J_ev!_d2Z)h;o*%9?9#B8h_rU?u|G`^Z|JwiY z!llwbb9Tf-#DHOc*rJW^EXK=YJ;i>+0X7aK*3o)9!Wz8!X>pvb$;$@ZR%HKr&l#HE z+o*XR?BIh(e;^%o)H&vZN2d=RqcR=yTpv2- zgGwpmfBr~x-bDG2e)J>h&^I1p-&g9@I}d3*xK?|Y2DE?K^Zm-}d!~vh@B@q!*I3*_ z>>|G)e}K-h15(7sh)U^e`jGe;d%zdS|G#!?>wnSW#m`u-xT(UvY{AEXbvz0Opl?$4 z+Q9wT0D8Cm7TRZFYwh;@D=nTfdyvh;2?%53MViZD4u!p0eD7=jj7YM&i$^U?e{|UkWiy+2~->kB7_J!%b ze5cgE$IkZ;JM|qDeXmtz^Yq^8uBEOYoV2LLZDaZF3^?89O21^Q>NmF>HdU9Xm41HK;zK9KIS{%vjy+kgj2 zxmLn&`dchXJjwnIam&gT!h!aDt8xXRr zDr3S8vWY?Y05||!805Poh|27_;70KQxW@+IOY~p)1L4bJE|tLt^36}%+WOb`6#Hub zFCLN)=+?a%4`7G*BAx{surVHE8}kRU0qK+R6S_y(7aPYuuIkO4w$d>FucLBYt|^PQn`;m>TCG^n9ajTx9F2&__>m-Z`$L&=(=Ce zK%4!2J*I#1u5WZFeQQsO>wKN^uK#r^3)#4F*swmlex2!hSh~js*kg#k*-Hfe*(=U^ z0pjrz@gO$oyom1USDX7%KO6RC8~6q7E8cZ$>tA#6GrN~8y?2S?Kjy+C4z!qrxI_|j z62y7%I(xEA=d$G$#CrI8{GEhy_F2Vwc%WbXu})ZFkDm37`rh7O`sh>tua=I%d(SGz zIyyHUOV_>9B|7WD9;AP`fbWY_r&q^r3to?71U>Ve0&In2@XdG3qAl{^8{N|ub;0rA zpnSRV@HrSCQl0fGA0A#O5zduI{~I<8o0ttq|JcAr%@Yi3&5AthMc4tlH!k4&BKqDC z9Du&zfLJqt2jBzx1MGvfhzo?x#amndv@iYNz??d|M{FL@@~n#>iUnk|q`vn~;{&){ z_Pt!`O1%^F^6axC->dgtrFSIXgWXGdvrV{vkiAGp#1C)**h9bQbd_u%&T$@{agMZC zy0?@vT_5@cw_qAOsm0F+&e3bHbR8JSj%bf^?mtKy@cmj((eseuekkp{N#6-ZcHXGp zeA=LONpXVoyk3~!dDG6a4e4F6EoJfDAS;tSpnvxXT63Up)E8^DuK+F}F1B@K^(|vW z^l!dFc#>V*Eb^~3W0JMW^k zX4$~7@Xs3c@CIRCd430mW4=3%{$1dYA?aW7fcTHOMdpZX96;aD2Vh722=>YQSU@q4 z`cnLR$+plv_YmhCz?#Wo`F`|GJb=IhwyvN3033iFkb-~TZ>>HT-qAnlYT5T{&8@Cv zz7^fuv2#G5`l8&AjZK7Im>tMA`lJhlaRv6k_cKT->y?h*hfS>V78z>ve-cV5_4q{3lJChT!Gd&moDAvbH8C< z^XGRD+Pa#?0j#yT4*>5z7i4-DSC#sA&KHMC@2fQbz7m_(8XEd0Mfa5r0vLS3B>Tn*tnQf?@0qK)G_JWQhAGS7- zbl?-ILvX!#e+c~x??b{m>3V)EVi)Z((f*TlDr3(I z`$*9LTCE=j7oc}y0faap?0~s}hzA%Ck|%{56bC*c^s_C}rOQ^{$hxSlspI=u8@v3ov`+ane4Xo-x=UR)=8Y}UDt+(I{bgozHsy)gh@I;?5f&Q@*en+Pl zZcx5gx<@RYH0;ACiV@8|#Gi0z7UD{DHK14v{SIi{jjdoC{W%}!Xp?fj|KaEBYq^Gc zA%hyb(e9vRt#prWH*DHudUxH6=cQ}%>`hv?euIwj2im8kJxl0+eYOL5AOs#j!WPg! zabO7R$jlYU_usW{-MXE&h2G;H!tsBzuI_92j7Q}AZEaO!0Qmv42RK0GHb0ELW*nnC z*3y|rGQFFQS$8Kc(EG67u6-D5w8z5sTj)GK0-L}#`os%v7ubON5Nro~z)tWR*bK*R z7j84?iSq;aR;6AC?4!@MrcdmEbLf_QuXw?1L1lw*^tyFvfU&ySk;Zq#a>8}L+8oq5 z^h~-=dL0DU;9S2Wj=tAx?-}LmumP0~Z_u&I*A5XIDmKWr01q%GFv<8|uQq)z5%I6= zRquu!FlH1F92I)mR;m2WEOd+h(RKKK*76yXFdk%VYIFV4J!3w`cGPDdhOMskA3y4pWzg)3%-CK&@uKy{9)%+hq_UJPl{VgCf_*jYyTKmNKbS@CIySR23w>NSGCv|i;sD_6d%Ss#Co zW9j@PViEQ;x@hiSbAPeF3H^irn74!n@Lkw}&Hsa4^e^4x$I+?9iaPdjpYsFy*dB}kDj?P2Zv_l=ZeU0`H z_YE*6+-SO{%s4@1*a$iY|HH%le&8ly9^A_&q;Gyx5bQHI?CV9IGAGEMa(uuB<_-J% z&;NVqdX#%^r@d?6L*IsX_Wwxdtg*1)nKc{AnZsmV*!Mib0jv!R=capN8R?yIpv5)v z(JE&TAGTrp^6Z=+hrOWp0qN29=wTZYzOUUcUG)h2{7y6H;nhLu4Ev%k=VA{xc7Sk< z?$I|w89o_1;5ypGPJ(Al-@-oe2)+XzF`K|HxUcT#B442ML&CoKdFh(+b+QBWPWv|g zR~a^dpno_3%>pmpN=$!ibEO`A4t>KH{Af0ts#c$1~qpV_*r=KNMJUv@ux zUhFup-P+u|=HYGrO1k%TSJq_U1E1q0&zv`7UUYA^fqh7@4TL?N?7a^Az;4hfddE(< z&d!r&n_$0oAAUmZa4xQ)9Q%lRgNF4XYlkvFFMGhJn+i+8tjXItD(g2Y^SpG-+KaGnen2|MCVbvY{mIxp+X41~ zF!xDabc<~;CmQpiQ@(7&cSdj`UPplW1H@s(m58mN_>HYp;rMqfg zQM&*6KdSBv)7W0TfARq9Cz@Ba{TQsXvR)_s*LykgeplH5e#7=vFrJYeFvi8-#l9$x z8RruJnjH`~P$nMWm~|57M*7v~mdXauckl>h)Z-c}S6iIJ-fWIdu@!mFqhs=JJFNSP z`_0}Me`6>36#R_a71vY7b$$)D;dX<5y*= z@2a&#>HZ7q3uA2?drPmB2RAV$k1;y-5xS*) zyO-`u8P|B3be-={dx+^>&&FI1{vND{-gn)&@mTE>_`8np{}yH@e>_Jzev|fV{B%(I zAJTd{d!$G?2KVfy_U^cduD%(tI(I82^&#e+uLLQ2Oj~Z!M022%%9iK8CQu<&^daJJzi#e zD)aWizeMeWZ}iQ+F`jFK))6;p&j7mD@y)}-YY*4|7U=$4^!-0t8;acrdV5#yD_xx- zJzg*Q1AQEVjy7s91-yU^X}<;i!~QysO}DDUo=eKumqogP{TN!CMd(-fbG=gda|8Qt zq+7<#r0B!;>*+l8(KkZdoFm@0d#WyW7Q(gF^<10J4Re3B?Yi`odYtDOT+f=@Rogax zQ<=TrVE?@v)(!nsdrHn$y*CUG4i5jL>1%60&#t@fI%$`6>z4NQt=iPrvtsXo-qp|2 zew+WJ{WmY^U$gq<@(ZsL&%S18pl=`f;(fFScW)*g;Sq@Opc{7~)VI863+7-=Q_y@%=CVY-i<)4dMUwTH^*zESNR zLL0PYZK`c?`2mvs2mAW=Q(ODWH|)!E=(`H9Q~7JPhiD&hj6JKadA0Toyw0of41MMDY_dvS`+C9+jfp!nHd!XF|?H*|NK)VMX`aQ6U zSKLmrJxMo5`hlMvJ#GJO_dvS`+C9+jfp!nHd!XF|?H*|NK)VOpJ<#rfb`P|Bpxp!Q z9%%PKy9e4m(C&eD543xr-2?3&X!k(72iiT*?tyj>w0of41MMDY_dvS`+C9+jfp!nH zd!XF|?H*|NK)VOpJ<#rfb`P|Bpxp!Q9%%PKy9e4m(C&eD543xr-2?3&X!k(72iiT* z?tyj>w0of41MMDY_dvS`+C9+jfp!nHd!XF|?H*|NK)VOpJ<#rfb`P|Bpxp!iI6c5G zD2^ujWkr5nu`T}~eK4AyC~tq-J9GEUMV884ZTWuj7KQX!L- z58;2lM!ubH`@Sgkf}TQe=n!3^Q*?`tcaos*;O{m^s52hoA9M`P(LHv6U0^fdcDiJy zWVU3kWPxO%q)XB*Su9y1St?nUWx3Lj70S1gl`3z`|Gp1e^(|%5|B<2_bcC)D^o9-> zNzmy$$s7r~o*|hk!3MAe_+cjrHUckTFYV8Rw+GN4dPJw_7N0OpGE0ITU>Dd5_*^6D zmkdgVB}lIy6%;%lE4|=lKB$&A720`5HkcHw7Ec? z@gVsG=FuO%9j=G_!TM^+fCM`Te1mE7rR=QZT_n3n9xmBa@@UCpC6ALlUh)LVlO#`; zJVo-<5wgR>o~E+@o$Ro~(;kM}PphsOaqh2sR&Osq;}+X| zp!G&POLPCy=kz@~Ko{r)-Jqk#NYL3H5_E_z(J7oz>X~EoPl`=o8$A*@f%tEhgcyPN zV2lLYYIA`);UDaSPKeux>*4O167)xm2-m~?q5H^V7ylyLeb!DpjeXUaG2{Mw?AVT@ z#&vXjdfd42=X6Y%a8XCc_$xa)I8k~<}LcXUknjndzCjGyp( z$sd$Q{#eO`3HK`BQYK88*s`4ZYO9j*YNMfUQ=8SfvYfVCwND+|o5*?3|8;3@Ki_A< z#7Ta?xUZ#uyY}@ie?;`IA%9R_GU2yMf1`7EOYRy!e&QWUZF<(p z^)j>kUAxQ{%1KzvB72p3F|5KCffZ5~i3 z{GC66dvtNcaCwxc-cAcC~EhHrei9CQh7`LUf!aPMVa2 zk)-pZifk>_GC}zXUYF~1uDq6W)%oIFl*?YNetBHw^R?CWs%!bCb{g)N>j>Kkv3JpP z+3`e`HA_{;-`lUFel4``@2ff!Ch43D{pIabKGFJ-ifOCJ=)I4 zO3U`SceE9CNyj604*g9w9g;RYpFL}Ro_p&)cZtWZh4aUDbQ~@l`7hbd-V)-1h!cq! z@FUA5j1w3~5?}5pY4d3cYO*zduEO{ZQfM9NEDw*n-)H z(n;6|_JDn5nTRcByTfjyycHH(mAM@|*5iDuHtZe+uBqEx)_iR3fty^nRl9Lddv>#J z$A4h3AuaEp-_`FSOcduTY?geN@#`4%dsOdUor`jC+4vldb-_272E(2&u}|LmqS6a> z8tX;sP|WZv#eWx$89VkJ#D}t_O_CuAwne&1vOoe4 zwBrGF!Z!TCx6I2B=QEcJCxd(ZJn5ce#*I5@{P^(~3*YyGbHjS&3o_=hp=?``)_fp) zEpS%iA@XLE1x}(o($?q4tKC-O_v6~QF4AbPAJzh_cOIU)-m>wmDB54ed1F zw+YwHIOn(#>&^rIj5Q`J{OW#XeslGbN^#@cWLv}q z;2&EfWo&@&!3V|sfU@n+R_X!9UBq3TlEsn%3Az{*Pd{^W$U3DgOH<4^ef$!ZtAWsWxHYyU;#x8uaw_k#P$ zlc!WMKKc&)Ptuq^jdtN}D@i+Ly zIDfj@!soL_PoM89E95T^W*&*+8YHo&3K7>dfVVR;)eezwzpiro2Xb8AqpGj|sQ5PW z=%T`LBmaz6?$60}j@3v5=gr5I`yA%zJkfA%Za413Tw}DUb%jsL79W*uGGYPZQ{q%? zxBdC+dw~9)6wR2TJT)9&Cq{I1Riy_9q7R8?LYG)cE!?$4i}$W4~9;K^2xp!D#SWSuePaGTIIP z!@gV9FRzWhD$62o++l6PiH*F&eHz7a9%XiK;T}DfzTI)?JW%65IHJZccnnNNzIs;a zk#;x^TyH+*eq599YwJOc?fdy$ruo7h$BfxuwuxWDKM|iYH^q2>F>CwtKkothjs9az zZ;gcYmrb&(V-3@RYp@RXi~>%7PQlPpHaHk@%|wp(F8 z`}jDga>G1!Uz8bs4GXG2A$Wtj_#|&XxFF&T&hgA~KFa<6x&3^PX55W-9BqMplem98 zt38Y3n7Yx<=JL^I@Br74FMYi8i@#^!vo50#T;g0{FjBim)}!+QTmTMhE&!Vxd!N>A z20sM${n>&O8ZjUDi@H@Ba}{|U(_zQ7VPk@GWuwFbj0F(Z2$-j4PN4mH;5`8L*{{o5 zEB)A`vDTBNzaPdJfAZwX_tzMoY~y~88UIHb`MMr+fr#qC5fvA>jm9xpcM)G;zb5cN zYpLx%+jWb2&2^fO8~DYr=WR9DRs9O%|MUF)TsuKFrDwa7eJ0qf1fRus z516-VbAUSG0ek}ca3|PbJ$CHaS81GY^LzOE@b@*YOMgFl-ub}&e~opH-M`jlj{nJU z1soyWw!%KwwJN7`SZX(cnd)fl9=PI%F!FKLDb8~R%U&g+o;+kj)-_*^L#Kt z&uM(o6c5C_0{hHlyTq;7F#8AF9H36v!Y5*Wu+R51R%`$5+ZoqSnj*h0pC9-i!2zlh z{yx%@AMp2z8M6P+xVBhel45~;4A!xuEK{gYUiOHLhV`<%#C@4B>y-J@)+cITo=Lt} zJcr>T?vJf1-%4n=Ea%uT5!V)J)U&oX(`HrAuv6c=dZtmX%a%}tk^113+9KX#N54=;}e6CelLw)lveqF10pn)HRecz8LF395nU2{M4gV?9` zk{_4t7#@HF7z<$I?a#;_V6JbxB-ZzO#Gyw|2KTNLVV`~#Mup4Zfd-7{*gwj^yJJ3L z0$r=?t(>$;z`Jun%>iK0%kb&W1HlD$Oj(p$9vgM8cp&(Xx}&CQGtO0fklQbDLq6)2 z`6z3df;GcipjqrK*zlT9-($asLU0OQ&= z2dEQ@4{N3Dqh!vMy_Ku9uJVbAlO6BUosIP!^N!)b_~uf_f1VF8tb^~#DRAD5e>;|) zO~yV)NXB`_c!~YVli&!haej#FTVhT)rJkK5jr_a6<#` z^H`wffExP|2Lz5I4V+iU!pRhPL(k*_z9*=R<3>ye2Q-umOHn?0>ajzDVcFmGio-b>e3*EQ$S z3AVwt#XTafh?M8^XQVG878yBy0MFQA9Ul;{fb$yn4aRK>evx>T!T!8eCU`ErE5HDkcEuRz_VgpU;EKEP?Ad_Ig{!z2Bp;UikEwe>Bd? z_jNA7hW(n&aRBFNzr;?oS(a6K+N8eHW@&}75^t7QKUQrOSS-sd-=h6i_oK{kUDgMC zxozX0thKvmVf5?Kw=+aAY!4#JMOsSqs6Dc&wX2Ae{&pQJb>OypI>0ze0@Il_}(!e zvA%P_B=hNT0Dit9RoPU^g#~1qIAbbx%5j3qCr>Kk0P>~CW-Bhp`DW}o_G>H$rdy@W zb(@d5CNMNf&k;B#tweRKZQ%z_2`+H#@ceb%iE1P87uVPAgQ0vsuxLVm$j4I7F?h@Q zIJS0p2HhvxYL&+QDop2hE88sjsJxHm1M98Q0{=0W2*!yMynV*~yqnKc+89FZM7-~H zy$<>C{f2iPQ_u5xUy(QG0)BTXRe!huYbmI0CS+k{oRt@apT8b1D>sa)pya~ z(PyMB;{a?;yvn>liTCC_et(L@Qq?uAXDn|nZ+MrTg8x=|urg(`&P@rP2>fSWD7ip& z1Fwd$jKj#sSRszVXqkT?eBjp*2V=)zET_>{aqQ*oig*^KslNHN4uh zf}hdx*m%zCdi(i0-d}y*3i}lYs7|Z<8Mo+MeGh+E@a()X67M6+GuO!OD(=YGuk5eI zaUB=9|KA)3#JMuHc%h@?EZKgygzpS82L5MS4`9v@-kYy|z0Y93yYu3Bn=h?70KRqn z8}>8iBOh})wkFUb27m(^@m|OOV4hgOBy#|mZ6)B-%RL5w3+m&H{mcQJW1Qf8pmVZO z>Ke|Kcg)u~54>}JOVaGi9ZQbCxYqDsF-K#2yhC84tlQ8A@1SR|F;!!`yrw$efWNwK z+$Yld-en!<#Pa^puHn78ZH^nt9Ov94#sCp-c$@Y4vJP#OoWnhhYcdDKxCK6F=72gr zkWKoy-s+UtF7sT^v5z6(iu(Ip*e<_2DGV?M;QMW?f&DXl@cr-|@4Z5O@_p|oVW0k? z&vM^ef8+au1G4XTyl4L}+{YfF=9oYHKQ?V*IAA<$12F=Li~k0ou4&WNFZ>iay^8mIO_>UB9 zW1Eh7rB&W>Z+2@O;J6R`E48@*U3Zr-!0*?91?B?(8Dap&I;{22);!1?GWLxdEg$%& z@2&sU@A!SNpZ!1hC-45>$NpvPAMt)O{wsfPeqVSmupjY&b3(}v&I{lgtWSqC$Y;z? zo0@qbIDl047vlUt4A8^}89R1g%gg>%CuEA+v3!9I!?^Bk*rc2`RnH{1SDnk(1urz0 zbKG!F*E@!49Jab|DL4;4z&1mS3-C#Wl$_vv5FFrX#Sv9J0AI2uIPOEj0{hKZ-{B@4|GY?EP zA0C)@oEz4sO-<9M5hK)PRo*xN9w_|3um~?W<})vp@j=FF#0!DHGBqq^TvuF>c>%7d z>^Ya&v2fw_hE2I zq+DBaK*S7@+WY`<0d|Nl3hY}<3Kuv(1orE=pv1rDu~*IqXK3wf&x{H720xTz0LJ~( zB%Nc&?)2V?6a72zRa{hXZ1iWz0mcJ44lq54|2+n%IiU3Yo)o_+K;TqF?9}%PTF%|K3-59H2g|;{cEUBNhPXh>Zc{)8p7| z6?-+QV*vC274{wD;Jp#^f&F}*xW5(tTa?j`#S}UR2Q-c|h!50O)Ujs^j&L6G*dP2k zK1SHEY`GbqWu4%F)~RE=kwY?04No-TU1|B-q><-Sy3f%!5Huz7%p12PW;{wGV%q3hs)i2bK2w)e4qjq@4PrwNxTC+0^C z^CMH|3gLQM#0;4`?3yTZEL&WWu?~Nf$HoyE|JW|~GaJtK>G_%mO6;}5BlV*W=N%h$ zS$Qt%R`?AZH>HLjmBn*K8>FqC!Oxd`0v`zHdiS_zTpMlG<9I!5#;T?HS^Pe{M`ODj z({7a8Guk!O^Y?ZRsoOQa$KKP`tfp455c_@BAkl3I7K8y8Th@a3GV1I->01hzDgbOqlh_wKV&syMr zirHOL43PaVc$;ox`~vfVeWlZ@H)UV;cwfyud`&HH-`U z_8V=oagIxVu;W~Ab?fuL6*lwhC-fO=lg1eNyQRKA58Mei;LV?K$RR%A`W3#c998585VY};@`D+&9hKXlzxJ$K+@OR4XjYRvtJkAN!rQNV^Fjk1Q=loH%DeN@x3C9gm zUq4m-WzU_Tx3M1W_;XYkGH&+wY_3Z?&1J2Q%X;R!^RpS|=^Gt4NOKv_#5MF;b!{%o z$MA&j%L4!SejoRPajt2O1>gqG1?HRmeUy_1PgL^;+5h`B<1ZKXSqFTmtphNgVb9Mh z`Tbwj_-~B=>4SPa;B$c`CqzEd<~;#E2B_nJ%GR*~L^cNYZ4FR)a64UN{=jy5%v?a_ z`(q4{Q_AgpTw9(O{$kAFV*=s>j}gEo_S-CWUTpx`YFaX zfn8GCL8mePcl@h8mD|{gIw6#09mKWn|7$*o@^XzJIDqFfJA(_ry^gV4!*#|o=fOI2 zT%I~NjK&K-hv4-!54dZ_%0uZp0gPul#*EqF--!DI|D^O!h56>ZjsxqyUg~pyRhjxd zIKbus&`sz^Y1oq6p8G-ajz`BO_~+O$oAcFilt-G!0>V2S&^cpzb)E4@=8Up^Z<~74 z&sjXf`wCMHIIVbvHkn`K9-b$q-@Tt5 z?-l-aFY<=*mUANBR`tyI{wzEL?_uN2B5nuQA%S<}1l8d)@}T+)Yn>-k}o7UfH6R6-whLZ|w^?TNs_BQOez*>2ax~}tyKLasEeXZ4xWAIbt@pTQ)t#i$7md`oj z-o@0Hu*9F!sHG8Z%!F+u7>*nLZyy4!*4#6Ff(uVSP3M@H;18RJN z<4)mvx?#G+et~<{iP*qm0sMWI;03#0c$r>EaKQ8#GYm_dE3rKlo)A6}o;~nZ@j%9( zy$e`tfh&7w!%-{DsjS7hXy2|KtxfI+9~Jj4^MUIapF~@6O`IoqS_lY#P zAdW4C1F{fjSv+Ofr#xd{=Pj=1IP*i9*6n5PpgrS?%#~G4KpenW!0~T!fov`~fc?Ri zcRYJ6VR?Lh%@3{flajs<|2ScWG4n%V9{}tA`uAvKo?{#sdwvW5-)i4a#Qd!|pdkjJ zUnLQjmGc1T1^tDcg`Jr1&Es*8(eeGn=Y=1~=bL|bA7A3V%!B*pdcg&jHsGIo_SkYMCI{bq#PUetekU=b(-r>oyvS;V;j*PF~C$kM|GUr4s17; zdmGK|M;+4UbIr$PUCRr17FRUAV_6RlFuYs)++)j9_c?u7a{x9_V_)siris^a8Qs?=fPT;iT%5)9X!eACI3~h>1P`G9z`t>Tc)&1i zae(8y#B|_)=FCpT0r10&)LG<%6D-Z=Ee^ zb8$Y>@_d~Rb>SS82;e_$G!U(>@9E|a|AV>8}aWk zLRl7hn?F!oOWTT(PZIVSGd~pY&pUKz?(ZD@e~gLZy*Uo-M+&YZA3R{Zz&wEPZexLr zfBb&@Ux46%;9hJ6-5PI)KFubCDd{;d>2~8WfR)Mjf$m*mwN<@eJWR#|BEll?zw^`@{q}E-;@BE+u*g$9>>DmzQ-ckF7^r zO*}vw^b!4Ib;>px@gDUe-8?@1ejbN3-qZLh-!s~((#+LY}GA8`P@P?uFUhu@KnhJ8PfG_X%<_@6l8JHia#lY1!OAOAl? zG5+@g|KaoL8~#sBV7(O=#JNZ#J^<_H|052tF<1UwfUJXv0enp9daiB5{i^$5@;=`7 zF?t@i7aUOe`HcBEF4G$K1qV!L4$#N@)5u2*V0ahqE#41&!wDJx^4-)2E5QeLjL*%u z_jef``;M0yLw2ldgKIoCsPR6sJ;#4=PlZwJz7X1_PIG&945t)vNO>LaVgJ`nZABWm zj^jw99~!PNp22xg?^1Aq>D2mf3p}89N)BLMpu#^KqT`4if&(bScH-D{Hgv3yrwvJovXU0lyEpSORt)%xeHVJ98LIh5cUz{tfpnV*kK+na14zh_Z}-IDo!4 zE}+jP99x?6rX%SK+c7;?enEC?wj1~da~_X_Q?TtZg2w_JS8+f-ANX&HeXHN;m{%VB z*5dxY#6Ra|h+inf-W{jnfGQryT#(OU^M+aZc%Kt! z-*LbEY>hYsclgYjOK8J+Big9X#s6>T8YD0t*OX*kA54l zT_}?c8teyiT^SmxX0If+~D&7;r~hXTlgD= z8|Ka*?D*fVqhrTSypw%DB98$q2EhNfi2qsB1NZSAYFqbLSsnL-bC3HY9+hEhi`J`1G;P|fbuQ37VBvIC>_+~!maG!a=a3f3@=D}Hk*$N*`m?5t4b702tRAN2Kq7LUg-cViH zw)LBE(Tp?dH@9QYJ*DOmjswHITj0G+tqt0@y2JyH`?#+0`TU;FHy-c1UR|H@jFsQc zc!meSI64ka2)tXlo(;Zt&TuZEUd#tXyCv=;AF*aN4;{8B8wdN2^BV8=y&`PVIKbn6 z$G`JSN5`0_3Hy8=%xi%5>{`!w62IRD{;TguW&a<39sH6v?31_ePikyHN?kio9dU;G zmjAC{!ZD)ri1Vz+0q}rfUphxWwH=pnfNVywKfcoNj}4fwcKB|Z$B zjtRq;jvcRcJLPrW{z!~REaGjr&$V`}{mfIjZEs)LpG=<@qVpx*$(R1$eLi*k87_t&yTcd zEA|N*@4~rR9Qz)}x*i?>=qhlJUY!Ss`)lkwu36*5uj9WXerG)3WmYG10A-!CW*HB3 zc6titD-rIePuDSm|F3ue`}4Lbvp9gbz&IdMIKh2>84qwBv49I$0JBOfj5ux_6JAc< z<{Ook<+NL3%>DjI+*#YYPdrC?O<6|2mgfS~?$2wCH~RPd4)CC!$DYC7$B$``xFmRi z^Yn|?<=RH!8PE~>9!=nU!?xX%wz5QDMw?Z?Ik$L>!8>M-(0TixRAGOz<-q*rQfxH# zB?u$OJXG*MVbX-dV~y0~bH)Mgn@PcQ#QhC1z41Yg`E$G`z=!_3A`(9-~z*c-M2PY%l*mq z_zY|*Yz*vkZ?ivaFaLi!iy`0y*?b6Pw!bL*ePROE8Wj_qBK$LV{-7WK3-cdl4bHI7 zyIUXRHIj_~z;Dd`1t%EJ3;#bV{xb(y?63Of|BHU+o~9%8!QL<9fl{Z1&TTB<^F_lCIv*_7FIWsi>gz`ohO z?9h14Vga{raBg_axNsgAg%7A}woTuR#9`oVODSB$J*n4>cc0^H$u;?^xRt; zo3b!R$-IsKFGx=nyC^Q$5P|IZk<#aUJ??_z;zi8?Sxg<3>n_%DK+Z zjb-n68Z%~`guK_MoybSpT&6jist)JI=-LpTXUy25%-W;PTBtMDo;N>#9P>_+F|=6- zb?tgm-mOD*>>l}8Wq$1GIQ8pTo`d>IClFJMBT62Eb1W7CPZ{%IuhcWKIy~D7`{+CH zU8Uj~$G)dg9vtdi>HgmC1ux|}L5}P3r}ISEqx=2L9l}Dy2G}Qg)w@*KXAj_m8UL(Z z?x=S;hxdwiFFAm9Jm-PT0U7_`xAgUa_r_G$sD3q9AgopQP`-*cz`pKp&p-+fWDf9k zAorPpcXUi1yhoX@-}#)txQ>pb_cFU!uwY>dS-5bK@{7`Zm6=xFG%y}D^IHP z^OTy*o1f+t!g-G8&Yjmn=C&xCm*&iwE1Bamdrq3&EILOSb!N>9nXP;+oR4GjGj-0d znVV*-eS|WTnSR|&r6#m9JD8ATw!8GK0BLn-679be3J$sq1GjKQfANFOQ*e z9K~FV+L)$s6P)s}?Y2+b?XaWSPsAD}{(}S1C%OcWv6kWUE{%8}f&V7#dyJ8N260lh z6SyG805-3g@lSif0dT?;E9W;K;Rv_KR%yoncV%Pz4$y;r{eO(s{JtFT2_IJY7xu|F z!~x;|w}^lDs}>`~HA)Thfqhb2Q}Gz9jarP6z@1a}AKmOn6|D+AW?T0_| zk#zGff0_R9hd-p>{`R-&o_p>|zxfUFoAm2n|Jvm4yYEiFl34nyU!}Y5x+~pz=bh;e zSi?YG~aZoBO^lUr}SHQjQ{E$NqP^X8jxPQUoYFVamn-IRX*^Pi`m{S5h8y79&v zO@8{*pQambxFP-ICqGF){_&5~kAC!{blr8=r62zAhw0jDuQmDp_rIUM_r33>Yp%H_ zU48Y{>ATC@UBwcjT#p$bGy(oRVsgO+7o;zJ=}YPS^S_k7 z_{A@#FMQz(>AdsKOXr?@ZaU|jbJE#opPkM+>+JOTGtW$)|NNQhbD#TsI^&GbrO$r$ zjP#k$eAeXj(?63=JMHvz+UcjKQ%*fKoqWnE>7 zrM@jL5H_t$*!S^NqyIM!QM)#83ZEdlC+$*Z_Xq3dU*H9eYnrgmx#qI+IG?{(*yp!E zAIu-PYpgJMDeqVLW?|kqKp3|;oz!^1=K-rZfQke1I-oe8xEhK10a9N}^fE7Z9^ih> zF+lXar8x$G7to_&J@lVb${62cFZy4lsb@`(;W_;;Klk5%fBMT`{*wOu=Rcd=ci(;K zPk;JTy7%6D(;xr%N8^>>|Ni&scfb2x#V^h=&M(d>nNJEX2_At%keWlBKT7Vn{`%`1 zxZ?*u_(4LPH{gt`GiQ8fvz&2R=8WKti!Z)doNRfH&ZgGsP`ueC~7U zt#5mKTGg}0Vlv`M!=mO^42#lh$pK&#Ro00-^c|S_t|d1e*^Ep2ZnKQ zzIm)S_#|PZ8kbC+a)YqL|H*r>e>#NyD~Y#D{97E5@n8D?;Df+7ykI_Fbtwz%m-`2r z^CMz_JT5Q}f{Qc%q9bq*-v_T-{}&u!alh<^`Gv6>BjZ>3fZtNUa^M*(mv{!#t#Dmp zJK_e%_1%gU6ffL4I=)-S2{p!v4UlGx6CZ$a-lH)#5XKc7TzTbHRb1dP!4>JU%RDZK z*udk0!1!0wg%^5U@Z~RqZ;uHoe4i(LpX+hK*=L`V&Qe@(=2>T1TmZH$E)a&#I78U} z>}S*IpHWPpc)&!l!KsQ1PB}%f!O4mZ6c3zuqGEv)PE5z2a6&rnxZ~4tiU~fWYlt8A z-urRR0pKY6;_&xyK&|H(%h&jK&d7Tzn&N)0!?)KQ;P~er*v&}ntITju96-K|^@9)m zcaviO0d>qCu}R0uF9dx4FW`gyqwRW=zGL{;JKB7R#r?$oc`RT&kmmvL=Z1CGNb0gl z6Nw*mZ2rE5&sVwQzVSB};2gufQeS6rUC_to_l*NG4`ds$_zw;c?qd!>N=VOo*0U?G zUt&M<%^VQ^yu|+l_wB`a;f@$9_;?|Fd~m>)eEjtqAN;5oAB>8BjSCnXRO5mxG%mQ@ z#|4*NdYO$2@bS&~cmMun`S)^6ko|i#COB7P0^wg{0{l8-f_hwVhWxq41Woui>@zMf z?4Mj?Uzi8`$A0Ql>64#4Hho;fZKq_1~muJhOg7q`!p<$u)P}G6OQn9u_xyMj|Z^567OM$;rp{~ z-Xfdh{{lbQKU(^qbWJnaAyq;fNWrl2m4+t;ThY53HH7A_}VbzJ0>-2)|xZ#^M3m~|EcP#qIGBa zuEp=_wR@kl&-qW-`+MIn>~KgC6G*#$2HlU||E=GeqWgdC*V?)t?0@caziRA1``OPL z|Ia-Aj5z`OU}DW=_1Xgul3oV?YERk`{ar8)F5Mr?*bZYpaXR(9!N5Bxwf*Z0c~em%e7xA4z-@0|D5>=VIGsxdDe zjgC(rfE@f2^Uw0ce47iT?{z$rF>hUuzma3Bxh{44|M-u?KKP?Q!an$;DRD!`0OTV?3?OZUuQ+a)u|wj9_WzS7 z(oQIG!;BdcFZ^}I2GRY`sy#^0Jo78n)58B}KJ#=1PrUP;?{eQIb|iW@b+v7LVU{*E zw#@_R@30LbZ*xG{er^43{(wiU*Ml?YPvE*!61=1PlEMxMnVJpzS&sfm9i*If00%k% z|BIz(|M&EhB8LmG!^0jjwypQu@xa7;*z{4}#C~kIai92SjxlqMSX0$$77oBBXyV^( zK%YRd1JdqK{h)TJ?z;Q#8rid9AH9zKOguhg^Ue5tGftoJHgIOF{jU+PCsyxN;`J}T z9P#=WbzRc*m_67_-<-I;WA}9o4*&eC)mNpTYaOlF+w)3bZ%WKwe)$)bh^s}+t>|XQ z>xtF>meX%4PX8Ok>E)0Anqu?+MlpKqZ{qW*o1c3wY;*MUv(J9k7=7lMXN1$Ih11Ve z^q+kC)1R(B^{G!)pZw$}tB-%;6OlgtiRxn?`*`)ykAAHB$VWa}J@wQ_swba(%JKfk zAAh2H?6D74pZLTl{T#pgtDp1r;+juapVIZ<0OA8%wrtfL+JlWh9VF3+#Iw;Pf^@|Ev9NixoabJ`UEn8v63l$NXXEPv?LnhwE>+vG}0$^@e<< zjKL>SR$@wwlhgZAwzMSgOQx2wubi|*_R|ub$F|p}5Pwm|x2zYd-kg{HrCg8q(7Sn_ zDep{z^PFpae9q-KHj&Dvr(7euF4u3}7Ru9~BG2SCFY(!?C4MtK9*a%q{vRD{x2Miq zfGxO4_8u|ci2EKsE$+iuDe!#enP-V>!Ut&b$R|JfDf7@Z*IdIlbycsbFV)#1jTy8q zR^1?rncNmlY*W99|MJ^?a9@r$b${}JIY4d7z0$^r`xal5dguPirzgsJ-ctS}VSlIt z@V`tr`@ZX2bwb;Lb?T|czvS2U(lj36xaA98DA=bgzqx4qBTEJUfwy56xmbWC#_u&9IA^)zq_Bx->xIe$MO=An$HAiB+bM7fwrU*eef+&_W9~NN663tc+uQXRE}egv#{FfQGTy87 z@ixasQkOZ_k+wMfj5BQSV^3g1d;%LnF`8$7Lg8#f<;vX4`>n0b)+5!#CTMmLB7XL(JGKV_> z{|go^{OiDfvG*G|r*3GYi~%G@$g|$+*q%55EHv>C){())JUGeiIxjYWzY~qFD{%nu zA6%Px;4sGoW_zbEK>H$hH{N(t!hB8+xcTOreLmM=4M6P3>51}8tSORd1>1}{DrtrC zc3P@!ty3;;TCRCZq-Blm*vEd*H%;=mo`G+c@r@J8^P6YQ+-o5V6H80VZ=7LEEG@qe zeX4!kFTSHzaD={7{qX!#^%@)-_{wr{kbG}^7F?JUb)Aq2baY^f@{ApEjBUmbBO^~f z`DFDg!h2%>qaXX2{ffsv_(9v)nli+7TY0E@;Dy zkK{HSut0i}^K3_IKkP|#*VL5QPW_Ynb&kFV>(1GZl;3^|4hY;g<$$@sxh9NdTzGN( z7kO9P@$7KGO*h@VpB!+D&*vU1G+c~)vo zk{*q>B@cWj7r+b26TuC9t9V6yUK{^#z%rgm=aa(&Od z9|H%fr<7^?Qo=#tgJZ~f$bn@d_mPP@gT;raFf7N}X%~>CY_Do&}_lbRl5F^021@gwXPzV_qScF_rZRqb{k~_tKxuLf&_7F?Q^x-jPdwpzW_}i!5AJC{hcOLLkqW+P zTd;=>L+)6O?n#cmH#exjgMZcW zUuQdq-KVy#+qrN+1OLhMHWg`@O-}=2^y2%CLoo~O@_&4@drabVku~)io z8~+gpK<`ucp&N>mx3LS_JivAyTcK~6kd~JO7nmCgC)9iZ)|HYEf(ODbuuoy##5u$& zBlouu*oPBH)EDwTi_A}{Th*caSByij5!{;XPaTI0NN4JLiG5^;SONRs|1;`Sc@nk*Z_Dso>U|2<*w?crHhHd@QjY!hd?t87axi^70rQbq6T)jUv4#U& zRTA&eya)at@i)of1OLSNYW*K}0Be2P1{4pZEb!ds1Z2Fy0mudNXAY2` zpMTT)1)4FzTM7r@W2Eh0<3D+zk%Rvl^NIaw_zyk^8=y4 zN&i9RdZtWpPSYOXe3Cgp^^ln79OK>Cq0X@XBdV5BfzwLkQ1n?jFI5<798G0XH&$e#o z!57)?#w*XRbD5La)c@Ee5eq;+2;0-wa54Ua1A+&V8=@|_=M-MjK4p5}y_(BCD*5_l z|63sp8UNUKu})Rt0OP;-cV&Aj_6zP)|C5^o+VQ@$1rqzV{~NY{8~1JObIuZdBNF)~ z{*TLZDaRG3Z0)k z(9FSUVm@;)ZF@jD`X60y9xxYR>$f?9SYVwR_&4_X{+Y^1jv1D1;sL}Rjb&{Irc17R z4BKE|4sd-^k4o-i7bcPWCibb**f0K={4}4V-++DdLE#4a4T%js{El~M48f;``wwf3 zz!MdIKR&>FH7AI83+L>M|9B4CHn1Ge6z#6}Q{97g#{>0D#y`)-Hhdv}fM?-2=dmCD z0qct)JE7l0_akHNlraJGQK^qk$G_gS;GZ_nI6&F~(bmcN6s`3E!Lzs&>04B~g_emKE8A3n&` z#J{(}dOOwMJqG)#3v-2j3nzek>;S%jhTWez`rmw^W5&ENK0OxD*0UiCU_LPrHd$aD zo$sV;fPcUjcnS*)SIBmPczHNji z_J!%x`(eX_d&&auw*5ZxWZ`+{62en=E0QNmtHV33l0KUOBeUMp}w!e1mKAk4#m+8R3^@;(U>mF_7RVF-Y}@v?ZU{dy_5VW;f&Wif_kU1*e)#=x z!Mha~;GWt4Hyv-ZV_?5u#dgBPcrV&`J{q*z0a)pFFJ7gGd15` z@u5z^|4QNgKV=M+`biFmcrR^UVgt=FTG)B=`;C3&i=2dC*#Yna_y+HZedD~~ehGd( zx?k8|3YKddAoyU3a1Zt!GvHh$@SeUv*s9pDq3bQD+D2Z84cFLrY*=!yGS>5BTq7KC zi}ZW`%@q&0_13Th4n5@HYM#~4 z-z?YV zq3Q!4cueU7_5r{@ZI$@1@veIWM&+wuyX$&f(=mVT8?)@o2OzPJ-lu&N_qPA>0ZQ^Z zj`I6CUfT`YuE!IUdn7k-PWl#!dCMB-)pfA+=N;nzuUx!n$=~wMZQqAB%v9P#$LaUU zI%2J)b^_0E0Q$RZ3)3DGE8GSLw7CGxFTqZL14^PFoCGJN?T`JRK5mWw^6hxH2f7P= zU>OKK*3|drG|2#R;`Kb`4P67#*G@YzPZ}EbxXBn^QLOE5;CT-eCT3>Be&N-VltgAMzUt4V~`37y1)~_pR?PRrn?V4&`No$pp zCMT6oPE?a?BCVMiuSk?nOsuZc`1opv&d~u4{Hxu)FBvxe zOJ2wNwV&KUj=o4eg1oSw&qD5FOP1`F9CbqcuS@Uvchn2-K7|9s`@BE=4(_wfX=~br zx(K^HFweNO_CELr;~Wc|Tdy}TpV$ZU99ycG-x9XwYo6pS3+={*c%9U|o-TPVaAO?(S!W-3O{|+qP-0?tz{UK;5Q3 zOb$qWXkI9CLVbgO;zVzMJNSP}xPM>4|NHC%yhCw8ViJk}_V1&;!!K#zU-u3U2z(-! zZ626v^QHY4x?w8jc{X!^k8&tV6jF3>j$!SV66WYbADO(OKSrC8S8$%;&lR93xK(Xu?|pK z8<2GYnOpdWU)TCxvF=aK`)|(W%{2g-OBnMBwf5h?)A~NF?NfLl`QhrTuhv??hnW8p z|IIvk0a-zI(2t4#J$vrAzyIF%zE?5-_g3$D&wI=RiGSuPG_S=rd{6rZcVmmItyLaA zK(uG-=Vn{ewycqwZJ+ji;+%8qB%FJkNYQ&!IUu+NnejUn9x&%1TU zr?%hkZ+*HAiF@Ji1^)f)Y9sTrkGI>|cvsvmZAJEx3v55y77n;s{r>qkJsyy{znLe_ z{~zHzZ2<6|>xF>zwhf>)Mr8xAu4t|&n(K(hx*)N3C~JbW*8^j%&{zkop5OmU&h2M@ zajXZL^NM3WKWl(~t@@hR`e@JLXWh@{nx9}jaUJXVwDHcIVde`nS2*VGeb(!FVk=yI z^)=Ojny-gVU*vxu+$R3P8|R__ckjN}{yw-TJ^JXQ=7ERRFTi+d=7VET1_y+V$@WtD zdC3DA2T1OZaUd~HhqiZ{V*8bC;~zb)1kYQyHRK|6UDyFJHXv-@kP+QVd(?J`arBB9BT;Yx;3;v(g+8xZ@ z!54rRHf`SQIs1WCbp6x>{u6J}N4%igwQG0vp$~n?xMwZ^em@+5UjUAXGX|f;wc1$E zHdnugViDH&=wa((9V@XPV_j|N-zf*f$VB-2;k(Cv+eWPI0iByR0OueRwttH*fdeSx z+Q|nlGyXfq|L;+o{paAwh>^y-27iKk+9zxS)w}jdjtPhdJojDg-n9GE_Q!|E<_Fit zwDRS|`GoIf_6ax+zBw))&^8I~Cl=V=kAAN32{R_G+^!phX zvP?FEbp>rodn+-Q#oKZ8z9yZXAN=I4p01l=R2$y9&3er zt>4<_XPuF$`ajkIkF~yXolorjSl_d)`*Yn8H~@cty3ZeLcm5mW{&QZpk2OJJ%^v3O zv!3UjcirXhmeAK{89@%})Wq*n>2>kD@UwgOZu|S-{+*ihi`@_Y=@$Ug=>I0>&FN~Z z)b+#&@cUx?Lf{~8vlAEaw+Bj`5FY{mpv?^t5BT=C|Em6e0P6$4;#fd_ zFCbz8xz-o!fQDVb8sA^7e*X{l^$Yk80QP;855Rw}1qAk)yZ^MffU*00bAwMdQ;iDU$aDed)c7mmP=;j|I^RMxru-Dzf-|~azMyP$QsW= zOscQ1Z&nzx{w{MDn2Q4bc}MyI(;rA10GSG%621UqfAViOJAmWhmiRh&BOd?n^?eRv z@_+ot-}G9X|4aRij{mvfQ2u#Y>nmb%Z5#b}wXPm9xi5aPy|&jEzK}8b-_#o0zo9j~ zSYuObYs)?dqt89}S=r`cqvyI^tk>0^YyVM=wf~6b-LXd3lhubc2KVtN9=AW9V{yRd zdlZ*LC%;oVn(?~)9=qSzXUz_+_XP(qZhvTK*zJsp@$wa2i$kxeYU+K{vY^9ei+Y$UlEw3&ENv=lZpEVKlt8~!y4zpFx%+x zCf@7gItHHMdt?APN7p4EB!9JaeOx>2ldwI&zT*$emv@T)->b2a|44mNHyH!59Z>2k z>O0~k_Jxdj+6+D@dcWWtEN9A?f6PxM{vT=ivfu{qgLU&oa6s7pv~}Ttl26Be;=i}I z*Wcg(kLjC}^T+yr$Rg_&F#eNgXMRXE2|g?;}<>Ou^hucVt>&h&SA_1{UEZDBo%&pE*bfy2Z;_YV%jHo#ucm`vFyE0w@~>hv+C*rr#y*BQLt%yq35R zy})mS6B^IdIr_g(IQW68E9(G-F0=P*Iq#iQvlW~7?>u6&i+d%l$Hn@)QJ5w>< z)6B1eV*4k$$F4_i+M}LM0lR4wuKs9uP*Kd+xbJqJpSGTg`>+WX!!hQP zz`C!aeXbMO58mN9U1y!s|JSE?{b4N^@E>)lI&}{BsT~vl*#GcC+W%mm?brwN%r8+L zHFP@T+-uAW*E!Y?J0Q0?pWiVDhB9QI@$Ja4WnOK8EQbz=HsD*ibK+n6pE19zWtnY` zy_a?#m^b!yZSur_m*)HMzHq>;x9#6p;oEP&&F6Ou!(h3$w?}z9<$iyEua9%A*T-YO zzhB=J)o(bD?Ot6Y%X*k|pv1dU&hwcon46^YIR+0nG5-J_Va$rknmoil-x%fnN%8*h zAnzaF+e2^PuW>)v{~r-L8WYRreM*7&DfscvJpt=>;%)JgI{ z)9&ZpeZJm>Z~)XxX%4t`e>vcGpARO9OMvw@-V^t|)!-oC9B%Iq z4D<_Et!hB!#yB`v;u>5lFdlhs!vQ@yr(4I7Yf`RVl>6k}`c83|Vx-H3A$%X=Tec<2 z`#S!GJS#1hkA$xTMu{g7GXVP^(wtqs=gAn}mi&Lt$$CUq>~CAIw)F=fIYLE5H?U1P_pSMtBeWS?8)PY$qTCoGZ*`dp2`yp*R*C;p=j~ zc|ZJq>LKKTYsC4Mf4zU$Ghm_E61q=auZ0W34(k~IAN>EH)ERYP>>K~^U~pk@YhWMz zdrmoRMtdobK0vjZ>e*Nq=F<*fUK?fP``}*s9~;2y-x%Xu6IqBjP~x8brvJzDfK}Fx z8qk{G#L;KNe>47=0e9Ycr#YZYen6|m_s03A zcwe3R`}_8~{(S9jRYj(BuGfiH`9be?wfD0QY^`?}cOZ{(N7YXQeMBYybS7uK7&$5#$M z2tPRNf8>BMrt^)Jx%7YHKiXfhW#mM^1s~RZdMDZxxl0a69w0V=Esear8av>QJBl43 zJi<@tb?}SrJ}6EI{Vy9ITnq1VvkkysT*W zNqYAw96+5Hyr&%yYflN2YO9F#qtk_F_*(t@=zZ*Z#{PhJ>--gJOZoaP=h(Ca|ECV1 zzO&8Ac~;7ZSEF0^2mg)-O9m{TdVXvH;~)FKyi@AP#6SD+1lU0)UR^vOIKY?&=pDdr=4@vI>rTQb`W<|;5AJQ-8~;(hQqS3Ip6NIKmly0O2ar<_Q}8dj zU$IhUvJ1dJ^>g<Ua z065^f>#ldq58RXR`@uhbtng8e{YA#C`_UU}4|sqwwvjh-Qd15%C(q@asVQQ25#I@Y z-^71x2Y1!aulL74f(O7p^$~m&*XH?nCd!mL1^U8Opp9ugnCd)9I2 zez-yW4DOXjqJF3^;aqq}r+b|Xm3d8^m1;|2opSJRov(2KE5rw^n-lxAcO=Gr@yk0@p* z{Hy;Y@dRF|GwL(z7wix#zV5o~?C*nn>Vt$GfL{Qf=u1Mb66;{g{e8Cmk)^;r<;H*E z1llS3OR!@KhpYXNjj-*&X!PyCdpw_cT+g~pc0YWcdY~Ft7asav;J0Gl*;DQ0Aev-=vs@Cw3Vg^IO0qF6f%fWf@0vs`5d}ANL3D~RV23^B( z0PwEoT&cXfr_cDekAP1gJ>Rqi!2UAuU(N-)_)B-(d6)b8!2KidW?axC<^aCi*=ouD zxBhSAe~Qc$T!DXN=ep~!ulC%3e|7uqw>!a|*a7$j)A5g7fpN;IXX+m8g)La*j&t(7 zJg(oEyG!ohXqz_V-BZ7Z{0QrMXXHI)o*Zr@77%4|FRsJ0kcSOu+l@du3xu1a$*_6ZVo>H9CO@ufcpIM zZ!SCF_F@OnPuC}$lEA*Vg=fbI(fvxi4}EOtZty=Sec!{ju#S#TeQ(>}IzRk?{{BJB zI@mYfb!>&?xvQI4;egBd4Kcy=12}FU0d5dZ7!$$#ms=FG$KS`^N9RBEu*U8R`)_~n zAwAEKYV>@K0@Vv_4dvI^;tKQNkNFHk5m+NOaoS^NF zz`uOxmfq)|sSDdfsu$`DyaevU2jl(Ad)IoO_N#4xTJHz;(c`R<-^4xJq3em`a6RxA zm}fgVfY=WSoVi^k1K4`0lgW_-`i{|qbJ_pMDsxei!y<3!F3BqI3cn`@k_XlT>)7fX zhXcScz5vey2QAmthoP0~0W1P!xx`lhN-_@1&zv>VDR`3t*yWj&jPVt_VI*)eeyE!*$ zJ~aM5xPMTwd&UDksCE4I?783gU!ZlM0#i|smL17Y$PMG?a~|DNjgP!qV}S4S9BVL- z-iHHjyzxe39lM`)POQ=1O&>onhi(bmpq2@p$GOOYWdwYKXZQoz#l|oDj(LDZP`fWxa1%3Y~x+qOIND!n}P=m65{*5sT}7Rq+7&&~BFO-|aZq zJsKN(x3O`@9e3I`zE!sI&5DcNa?7p4;LWm~Z>X-7|9s6g*H%|akG}0~S5CJD^w@$C9-uT8hSC?IOd3E{aZ>%o6{0-Fw7hY6t-FAfaINXO1fc)blpySN} z_5<(*uy?^ep`X;&hyY9dNt){PJ%uJ0Q=QDd%^rC4fEi&;R_-o)5@;M&<#sJ_zdq{Ka4VW%Xx& z_Gi^gFTJEWM}JyC!N1rxWE7Y2W-a=%Ku*|UV$?bbCI`s z0IY$3(p!Z8TW`I+y5WW!tLt@L61xAYtFAKEvH!sn^_ci-Vm|nmu~?y(se8`P-ypBH z0aV6)X~X0u_Lniks-@5j zXABE=PA2Lkb-!bQ$pP{W&|~HR`M3Euoda@CQXXfYHAI*TjQ#SXAN|Pd1hGa4bASKS z-~O%F1Nf`I`i|ED`PR3-RlV@Si<)2f?mEub$UD|1W*%%hhlH_V0KNuP=Pz z-&Mc)`OjCs_Upf{`Mm#Q^|6nCLh<@hz0}Zx7ht*`|pc?^#4`T%@51=2ltEx zxceT)?cD46|HS{%@8G40e{?851bD;tHs(~nZ+g?4?fYM=@2y{Z?X^nRR5u9!SL+^N z3tZdxQvEh@pLkCWPHv$6sqflelN`ECeWH2Kee!~LY@pJn00UL+`io5B~-SM;*2IvEMlc?)eU;`}I^->-l;c4#0nc zJ0ji>-s4+(4cr%-0{nwH#=e+;;T&C`1YhxeSz=qW>HqJ3=ev?seFs)DO&)%wo>kWY z|G@#gD=FlUHE&<1b*{({maonjBi0vUUJTDc>>hbT53x-db^x3h@qoJ$;`_`QYK~7% z{G;y^|EvK3{{Q~(g@3IHg8lOCZ+~0s{{3%W?~nBiSnr29{lEVQe^6te`TJk^!WT40 z`14+;<5{iO!#DR>(*s}PaK-Br_t7VyIAS+C4L(J`4X94#533%~|M(jC{CC~6%h;zs z5MPCUf!5H7^*?gbtGXwSkiK?eKluTUxcm)o@b}n>1LDhEFZ&-G;H__cYmNUV?o-cF z#y0hvZ39MWyE;~*ZT6d75c?^|4cmY=Mz)Rh@D-9LXis08>t||Qn`_b+0q$w%;;VE7 z{uTclk&pD#u=}H4z*gu*?OQj37unodHzntL7~q~*oo)8Q0brYi-fwe&@GV>i-sAf? zsq3-*+X;SzmT!S;GdXg>^JfMwzplaKD)P9w+rvLDK>HIZS>vUX)YNc z{;xVkKfoJ~8%RdLQrg~yuuXfAH|30a z&hoaM-^P8mD|zaMEK5C6_lt^;g6s8r@c`w?3Hh6ua@rYu_#V0sGN8E$9fN=M7yS%f z(0q6BpLGTH0)GvD6lS8$@QK=qZ(cd>hYnZbTRHJf9PLZj6Zhvh^WS=UxGva_SOPiN zXWj>O4Cj*DHbFj=th133wFj~9fq~YX_J4k}fcFVblbl*{Q!}e=ffPHA#(y8z<8JZo5>ew+5$~KfHi`DsI@||0sf9)L7sh9djB&|Yu^4Rjr%7wU!O7j z>o;t){S&b;#Q|jhgZGFH5-*EB0^$MaZeojf@6s4O$0HSsyos3PZO-Zc$Dc6X!G!9u zZA;T$aV_wV{wFrc7(o1ebp91eY`^KvZ+5#T|IVaenmj=LQrFn!iSy|>a)K_W99hmb zM`j|%S@3QwhYX?X64&s|z6o9m*~1q=9(fkc+3FbltDb%q@sD=hCHDR9)R*v1-89?D zI-mAvBy_y}dF6?J{QcDLeZA6~!oR z{>{Y!9Gm_JC;V7(K zWAVKm=IpFrzd>Uk)h?1>lKp?h0>OWu;wRVuft$ep8{YUv+xmPf?S>m~a(^J(^hbby z^#3x+1ZlbSM&Lj6KQ;hZ!`~uZeDTE|1MrqBu8_{ZLL6|V`Z3<7xWJoSkBNWsCf=vx z&|FKIT)c<(x^~OEeCoaJ6(CvwT@E(c2q3|EE z|0CYA-2IVPs?U-*AnDE0`&WnyF2DS8*JBg+mbDpmFfyn%L$;7V>~ZDIl-Q@<(dmq- z3a*#^DcSb>(e@4O&xm_nmvLB@H=WzgL+&m6l6U>KBlJJ`*E{~4`k_9vew-H$XxjqO z4wiq_fqj2t9-g3Wh}RH1V7u7+%6n@&Km7gyY-jx*JArLC$tAH9#iC2R=O5zl8k@Sc5q=zFhw zV?Ky)>4KfK`@u{4dFFAroFrNEGbnFAW0!G&Jv!U?zwdqT`?mYh|8Q9I`J3C^7yEzX z#*GQZS?&MO|MLIQ_0szu(=UC&bF)u*#si64qD%O0#$0wl)BfkY0}gntF*&4IJNlNy zx|@u3K(EDk;g}1=cwE}%7dpu@ma&oP0bYi7ozfW;H#szkD zh0Ie97hof!yBR}tt#toeuY8;Qb&WStx>{q9ur+uFxCHx)K5Lm-Pk6NM7$_$eK4DH()MS0 zPPl?+jg&GKd=WCuwc{QU>q{Qt9`Fs<5!7(hI~hB&SX)}$TjfN(m1{qCgs z1Hv(wL;e{X_^9+gWAt|I+Fg$=yi+m!b+Z4FeZ~PzPsWaA8y?meK;i$~^Ujyfzealc ziaLRP{P&U3G4+E){15zNKj1^q9(iqi?j=iz_4ZWfo_nq^{|4dxQYY+wY=Da{y4deW zJQeI)KTDRv22B}E?3H#aZP&)W+7xX2-Z@T(YXr_S$G(Bz@EyZuAcl`jab2F9STxuU zZlN7(`PXmI4er|e%3+S|I~Yr>MiZE)Z6L*6AvV& z54MT(bvxD{c0SlQ{#D*vY-hNFHlV(^!TGTvq`u#QTjep`5E7bpYn7-Ak`2Sq^*Ex)FAa=n!_&s#TFE>(SMu%a!O8AhG@j_|Gu|_5pN% z>saNq2l&sqV`rXqmi0b(zv!Zis!L?|gMaJ-+LgErZ4+3OZdErua8 ze@HkYH&3gb>Naj{;{(U|-`Upx`<`Sq=An0~UD2oAk`wEG$qzE*cT-OMZ}XCI{So_dVi_19hENH8L{l z9PB&E1{ic2^gCcr!!3-nB{s--n@r>56Rtn7g6#kgfPL!2zJ$v00cdaVe8w4P*=8rM zc*!NCOEtIPa$)x}pNEam#FzD~Yys<8aR}Fe4><3vv(7f=FTC)=iewJZvz~X}d44A9 zpLPfT$Q5}lZ<3$DtnjZo1;eCTPC3{6=&sQD@Bk@vMa2C$mcQZpagWq1Mc?V1&@-{0 z&q3aNKDJ4E){eoy>T9p-R@ibqsV?9^-pSA9cdt}`@FMQ8)rG82s#XgC7J#*RrjGqJle4`kdf&*wzp4~d9 z@%;0&e*awff8m7}s)brNpIAn}-iLRHSVni%5)T9y1P}B}R>6HHcwl5izP#)JxZyRg zd5z}%%-%QHlb@JP@8N_#VuIKKYHP-UoO$6Q=z*B*F$p)OE-!~J_#Q9mqGbO)kyL0f5 z{a@Z6p1JL#T=tE(oM_378fO@Bm!)6BiU?df8-D!Aay=En)@(!nVje58TSYOe76Jq^ZZ~Q zJ0RaRY=B}1h~rx5e#sg41NSZY2Jiqm9PsMm0e1xl$lo8<`1_VHNMd_bb^+s%w{G25 z?L2O0b?mV_s$-AYQ5|#4vEDxM#1pGkW2^LzQHNk1+3!`~A8pGvz65*^erfj;PdZtc zMMq!YSirgB7VLF+fcnO^&+$X4`@uc776#B2wSnu75N*kWBltKk?Gm+pYq4~JQF#I za(IZGeREQW%>SeRPZ-;qb>+P9BJD@JQfI{WZ9}V!_osepoiF^X1Q)`*v8|jw0Ca!y zL1Kk{&S5{s&Ix~T1xfYmgzhJCuS}B7z<%3br9X`MXtVqOfq&L3!R}A}Z|v(Guvd5< z;ueYji2ltS|68d3Kh9@9BZ+qylwCut1HMZRXvsGi(t6&k>kIzj0&t%R9w0HtV1MlZ ze1-!Le4T!$z5rbdT#P9eN6xkA!(=Q1d5?5EZ3O>-Z_4_`9mqZSPx+^PGno@~9_@Yn z@h2GT=bUqHb@th3S7#}mrZE9 z?k_eda>sSiHI3g>mfXa)j&+Rwm*2J*xuQMr;auN(M`R;871^Rb`g9KUKp$w>`)T)c zO!yT(0{gL#y${~8m)mxK`NoCLH)qs&T-&nXXW@BtU)`H{Q?6&1Jis^<`o(6$KmGs6 zBWuwRw{%|xGNty%MknXln6orrYYA@I+#dfsJ^uf~3x8UHf6r$&-obn%cwlH~Q2LmD zI>qRbNnM}5%|XfLkj4e$A4C!t2(Kd}vgL*2*ZzX>0Zm_E*FC$c*XjFQ;2PYg9bjHD zFQ~7eln+YIIiB%{0oiaQ`}^X6j47B4RKMm8a@F(6C!bPXaQ=nWnP;9|VZWbo#+mXj z&aa5&fgj>~*a{IdKo>*;OVl?hWBMnacv5xtIp-MfV4rKSf4b~~#1q&AZ#Dm`T_{UA zL+&^R&XG-U4IhAgo@Z1%=s6WA~NHNysAA0K~^b+3hc>;8SP-^M+4nr%xx+g24Pfq&-s691+Q+y^i8dmbwN zSwEfS`#x_&bYtuQ?TXv{@7OZ|1AA~FKGP#qQmETPD5fJyfc=WeK??{ zcePJY^2ERG0O^15J~}G@-TuLlvB^GI<-42v+b^(t_ip(saDZf3_(taslZ5jjVH(`0 zj{w&59P5AN-khL*O4TjL;U=zOdr!LjsH3*q1~~DA6D#nG4RH3^=Ts-1bduUpbAFej zC(~|5E<%SQ7pebkdu#u=etQJs;Hp}cSY*7w%4B#-bMBws`2nIo$z?-=`EnA)4~ zPThj{tYhlddY5`pU09DuUajk~^_8%#oeM`955o_zT`XB|N%mX9hx^KeIdF#`VVp}> z;^V>b=KR7#J(;VWy#A%+7WqW~2mbrq|8M-y^8Fw5|1!m@_(oyc{y9D|`X=SW$oC)A zGw|Jlt(!Miv-$t%f8$@{nZZ4nA5dx)4j3HhtvO(17~4E}k2)X@fIS4(={HP$OrHRD z0QV~ zf?@zWcOEbN?o>SB`0CVCPOVNj;RMSDF@nTA@?qOizYUCr{hxTlb(m1_uxADZ2oO6JLvqtdFY(r z0`6_z0N-qjC%6aO+&AqHKVMJh*#D}py~bA7VIxQ1dJeAMpZ7LKg(R`%{zvM6@ZYa? z^fOD2((dn(K56Oxq$RTdkKDG!c0W$QO#c7UB@2Zay?Z8M9-PAmt--Jb*d7`Zw)9T( z4m+e`%mDp{T?8pfV|iUaDx*%U+oth5cN%+Bj0eskw+d`q5JUzPEdZFbog=FK5oZO;~$$U@#a{g z^gsH8vczNJ|CnQrsj-hO0S}Pi5`LS00>5YNVqZtwiMhl(_~#joJ)M{Pp_>Z!b^qi9 z>n*)cOLc(m_w|hb*v8JKoN*@DmTZ&k!<6$m2btuVkjKN0_0{;c`;s7vjJ~a=7>~U@C z+ij-$Huekt!M*WsUgtWBL#bZ^{Xbj$5B-m<@_mv%J?DU~3HFKi@yuYPMQlRP3jVhr zdBm*vfAPf^Wcx1@k8~UVL&81SA5pw-uwQVEg%>Dj?PzQ7{I_K(;)yZ#6NOBXMwy2Sxu_qXi;xFGp}e97X4ikF{L<9GVTpZv+6 zcpgDw9u8PDF&@|zCxB5nV3+iJ{>{Y!d{_4nozL&M=4#>$#Rjk)z;>|(IH%?S)iw42 zyfGp>ify=Hqt**K=IEoHjy&QB#RHD2wr}5FZ9i(e^*@-1dC%D6ML$SZ@W+i~Z%3SX z$FVz#FR|11MA{VO#ERn!BZJ+oz=HLSz!|%7;X`k~rM!yQ@2Pfz`RL=7tLu`X< z&G{7oKoBF^AqM1_k9|d1fB!`v&a8ml>fhEq5A(d&H;TtHi~Z5 z^JyEM5AJRIE6%Y@vBWdaI4z;OPyUs!eAyh)a!f$>r|gDN;eJ%u9UURjH#i_nAEa;P z?W%u27YFRR=kDs@gJcKjnv>#^F~u6N|4C^J)EpqY%^V=SgLxADk;Br5_;Tylt*f?e z-Kw4&*je)~qq^kcl4I{fsLz9>E>$qP&gkZu=5Db}%047~2xZ9T)Kg z#wT$eb^~tXX=5(`(~ZMd$C{Ixzqt=*pM0jXCE7& z#=LmIaewT4lKcO|_s!eur_q14_ff+M)&0|M>p}ZB2WdBJjU-@!^tp;UB+Wi9F+eT%#r5e^|bM zm)12t=j=0U+)lq%;-5Z)(NX+Q#`26LFU6=)~0-(3Hs zrE4ZezzN&<62~5UtYZjZe&^1e_ATH8u94@FPf7YM*8_vbK3t##wno( z+LrS;AASJuBy@dK-c#W_Gm>Z|5}CjJBWmOIrub=s1=)8=rq?SAx;{CsqO zwi)H%pK<8K|ArO2`RPw*xBr=&#J9_sf1EEnawdb>v+3Kl)$z@9*sr zU!d>%q_+oc2Y`KWziN!WxB>Sio_qFL3E3&WJB80{jsB(b5dx>u`@;V!;T?<;16aL^ zetmI(64+g!e#ckC0rNGNfw%#F~nnzKE{5;(MKO$k!b7G z|B3zlE&FNngFO;<02svPn{vL|F|o~gY)4QIko|EW|?oudp;93 zisMsyZegHf@UJ>>|3CHCd09!`=aLm z;Q#l_UjYB4@m0dqh&Zdn|H1LFY-?hPP5Xbk{{QlqzhwKLz63ZRk~l@%Tmye#Soob7 z7as_-BjUw_4rGkb{>>S>SK|W?JLDig3;3rmkeC3;c7X67enE0T@UHy;@j9`HwUd+P zfQ=hAmuiJNro4w=p=;FV zvW?#m@545nMbdWX;9p$kSi8n=yFUEB;bW&yMZNR>w)skZATLoj)KU2P*7ex>!aqE~ zm>_I_bAh+%N1uY*~bo0ISJg;uBq?M0}Wna4lI4ptVc1I|4+Yv>VNl1=sm!w z@n5iy|BwDZ_L!q)#s5n$zE~|+|6gCv3SpqHnpiz5-7MLY9WXp3K2RFME)a(C|Ia?_ zj9IVsJHPYW8uxpE#w8yv-(;~9-%78)nY>KjLsw$`#1-l*%x@^FqsiaT$O|`lh3#g) zz;5{hhaGaTxnq@L|D?15>R5xY4j&K~z}MiMh##&J2TWA!)~=1Dc);t`Cx|_OjbOgV zUZXz&?8!#t8U+_R9y)=T5NoQ?pU9X3bUxQ19sr*pPsTLZ)wys1*I+x7WlqO^F6S$O z`PJj&?$b!!PEKDQ_$Q&q(dWn#`-yXMbO{(|I}`idn|>18qJeSWm*=7!S?8S9ikFU! zjdc$GrT?ivlJTs%YsvPeZtM@a{#2)~JLzQAM{**vKpz0f*w4IR-ZnROx3C{vK)Wzr zHpaXJ4@5ufD%C5TO#5QrlluHz_!eQC!2`h!!=8JB|Npa}{mhVP;(>zb|BHDFa1rBt zZ1W56Iff72J*fR9!s5}}U!Tz0$7Uz~zkI3i->W`H;e15)zU=_=Iw_7Oy}f+dV#WW@ zp7mP)_>cck{oe2WZiO8{EFcqpfz$JmQ=a?3|NQgSm%sESedFUdsvrE|2flXmeRF#X z2dMu5-(k)8YWo512gE0k9e|$!4@?M0-d7oYGw655B;n_iI1Ud?${v_h?60M^Aq5Yp z?u4%#N0c&Q8(HUIC*V5}13=%?r@%JYhZ9ow^E>bj?nq$YxK|lD=UFb`9&M9^Kg(;b z69LY#3)=Fk^K`Amcjlq@^`7Di%GiewkVEdlb@Ms$UgVVJKIe(|Iu8HD*{KWSreF*l znok?;V835jqRqfDxMv-&#J_ES^#2U_Z%OZx_+8d<;-7ml9-ADy%`yMPy5{%|>)EhT zNZ15}DqEudKjwbypZ-_>ymXZPf8$?Vu6PCZHsg7gNw;rTpFKF@FY%Q7;D5z(tv8~W zAHF{}KE77)EtO%jgZUs9j9@(P$NlrY*ybGcf2WN95$5+&pVW)-CEO8LqYi?91Mh5828O}= zm}Dw!dFnRwe@oaVO~*g6L2OHY%WwECeSfttpzGrM5Ab{09?UbReFw^VHTFYPj=j&= zA7X;cLnjtA+xVaFm;OKO|9;x7JQsEfJOKW4EZ=h3?7tZQOXTb8y+;D;;C;1Xe(3*k z`2+L`Vi%zQXY>D?IQ^I0ZgK$Q2 zeagT+xP=GM_u!p8wlyXLe5wp=f)iuka?&zy5Ceb%um$i3h$T`+Oa@)g?^^nOVm-0O ze#l?JsIeZr0(L#`4;|6KKXS}5@Jx;jg?>k7z<=b)7nG%L2iwdKWIu6^yf^WmdP`*; zqyL5bz35voAJ~d|3Y#fn9`Gg{>~UYVg@~)-?_!&)4dnkvA0PGJ_W1+z*jF(}Hu3NN zX0eLz&|knwUOGDK0mf2#XjGk%RYe`|FgvZzyH1OyZ_(#7v3{2nfNEhhW(HK zPi*g~Be&L?xQR^)(f`Y2|J(N;m!B-0lfZn&{)q*SDiKd4b~lq<(!B1!^mda27)LOF z{yaS|Hh<{<)c1}L$mSZ8?9*2O_Kj!x1C+tj*aMzVpmvJ!Ly7;Q`&I6^V8K7z@B{q_ z;GNh3wtzW6W#pVgi~x+KEzslvwkda+un8}K#pDHWjm?jo&V+5_Uf1RrDKQ^%9lpPh z`S-ywtHgP>5#Th*JNM3*K=J@~fzH{hc+9BMFZCbef8{&vwatysUpTU^Bh|V4<6JJ= zN%fNTKzo2)uXO>ww2e(kT-)PXB|rANg>mrD@s{FaU>+V=t=N<6S2_lN3?B|2piRw1 z!ai|6xPX1e`Z5=YxBxZ+Yt@fR|IdUoFzfL@8tbz{aWwZmp!d@+(Kh&}?=I&5EYz6Y z*}wns(n~Me{zv~~;~W3OdM<4LVh>QB@%x1f=2cIB=F>ce|Nf=1KHK%19I#8izyghP z1pn?2P`w-Tl6m5N#=6=MoMRhgTrfCZxEB^UC(0vch^;^wIl2)nvrXSHc0u+dU_anP zY}~jZ`WD3n$Pf5WnM&Q7Iv=}$W8~wCLzpA9kIeYDvJWhql8@woI+vW-27p_XD2ES% z3nHG!b+{+j;CiHpD};Z_{Ybo%&(m>o>%k5g|BDYJo5}G%bEN7G4ghmeUr{gi^HulR zM&Jn@P7aol4Pt%Z8(Sazv(J3DmT+$#C|nUdpz-RGL#{EXbI|?P|H{Gkuw;rHe7B7E zrY(SdFj(9Fv*DllKiHS7QI@(tp9#z}UJv~b2Qati=6B(@9Xxtue|bdQow(+d^#62Ie@tbtaT0tVCxeTzz!hJH#xb+Hb303 zR@)IDh;f8q9=Uf?8%4WfKgE~<`wZd?bbiDV=nL#O=F$Dlz68b>zy&#eB=Mg)G6wE4 z2bYOO&OuM4&A>V6XJp5}*SY8tFzvX%_Sp{KHn!md^Myh9Za=3x}axA$a z&vCr7L-3FOS6%F-PJm%mJI3N4^;sCbs{n7i0eU4Gq+Y{e9d;00msE^{O^|k+7 zY=HUZJ2W|9&z}3VCh7wF_JMz%f5h>9V*c4rkTJvb2f#kZs6%8R=N8cJVA*Y|c4Zs> zhqvLC4Z^zlMg0fF5Z10$UxN50V+e`+lr`jzYk@u6h2`2{l4syNe#5cEGV+laCujb6 z+LOrxd7R(y+ZOL5?w}mpr@g`bi7n*w@GKnT-q;5`hn}sY{D0$LV`^*z$W|gw!uwLs z)C1!>@cqq|s*lw7_NApug9G@TY%A$>_cd!wTVg-f$0CMHtch5WHU2wtqIw#VF zI)2fGY{zddQh&oKr<|rKrmo# zP)-a0{G;o^aeEzu-~xU_+tOE#u1EjF0r(lj6PZVVe~|K(SRJd~dL6 z2QW^Mbx*-A9KiZSY?Huya5(Ry@q@w&b^!H=jQ~Hu0XdHdUTCo|Y``Cu6V)}>VhmyU z8ZoATdrgQ3{LMtLE}U_GOTR_uq#jNTTK*E7Iu}krf0LTHPyO!ea-I^{w{8Q|jg-gg zGM&qI-V1wz=SVw*XUSuf={Y;1{}2CPSlSD}!iUU>ZqbLLwxm9&17f1Vf!Ok#H>Ult z-LdE4fy6m>AO0h<+TtA9*5Dm~5xhs-KXC83GUtjzT55ME-H*6G3GTKnpnGB$n4^S$ zAD8~;J9BWvPi9;H2mHH_Db_9G-sS+X&vW@5u3YLaA$HfHukm&f(#fIEj9LbUxp%wlAQ#0PBB%|Jm&S?-l$L|KnMC zX8Rk;lfQTt^mF1r73XyPhyD-zj}YrK_5=4|wT*M(U)Q4y-EZt0^Wp)xK#6m~F!G7c zU8nx|HHyW-1K=6l<0FG>&Re%`QhjIZd_8i`^>LNM0pQ-(&~GR8o7KAaBF!(l|Ni^) zb>_AM!~qKy9A4uTe1chz3l#R@d-iL)Ky8AbkiJ0T9{zw2;DWYYKzxLCNw6nI9sfuF zb3JrESfZSEVLx$}dGY|Vk!9rQZ(~^4%x!E3uwC#OGOXk1HqNzvXJ6aaX)5Qpa0k4? zdEh^jF`@k?{@E7x(Tg2{|3j?*NA`lToM#9AsS|S7nR4ou_oh9t-G{OJW#55&^k@_R z#MQvOaW6fKf9+)46Jvz~0{i4GC-i8<09u6u!2hb%hK^TG89ZQ{ z8oQcQ6yfgdYo!`;!*n&;`TW?F&z^ZW#PQhovK5|K{3Ox@dIgev-DmHA!vcYn$b)0@} z=zV^}Z}BVfL6K=<%;6_+PdF#!Uvi>-Qpl0{qciZY_`h&g;~zhty1=)}cZW~29cTmO z796?nPgrURL*@bD-1fdOkA02&=Ug`QV8;E(!F}=oyxS^rK;FcE*Z}nJV7rI!4_CGE zKR&DeC;pEd(f13-v~7RdKI|8sJ@CI&_W$hhKkWZ*`Qn_L_#e@B?t>TP8vKKE@Ci=g z%H#lJO6Q?_;E1%>L;r(o@rV+~;1c@<+6UL0v^FXEI&lO%;p<}?=suIg)0MzI$uV}t z+{pP}IqL)E-!I|-#!7*I`r8xp;FD*j9}nD<63<{dQ*r=yp8WvTZN>;W#&5VLbwW(R z`d`22T)qXxe$3H~{&{en`WDb_@kzH^N{uAf9J)u}pj{if~rOvO* zbPs-m-pBSg1|&a>jmdRScn~)n_DenZM-PwxuX@-^y-^RmD|n~>Bi=LWJvo+m zfbm}N1MZ2bg*^@a+sUzhbbi5D;9l!ZNw(nzu0sMJ6S4`ZZ|r^UZJSN)&6uAt+h%-2 zgBYILS?vpFFO&U0Ci`GE`yc#+ePRHi7lH%0XW|R{AFf)yJn)}5nQs5nk54}Z@4$PZ zuSWF_q{KC4;2-&}`9Spv9~jr@RHei{Ws~ayv#U5(@Q+O&*q1*bJb^aD^3~?_1s;0nAgTd!}2gLiC!^};{g0FJYN z06g+bhIOTA7{7#%M`_lfu1N1s@xkQ?DU6FiaUgso3Jfp~*tLeGnD01wzM)jrRVeuIv81peVL z;b|{*l<%Ebw@p>*JJyB?of+{y>Pg(tu>YIbC+3GOkhVX#L03|a-k+KR_rzS7*J&F- zGBGGUkKRY$`r(+iUGX`v>`Ux0g(2`z)Yv3yE51z+yN*x3L zM<0Dujgjfs!v62=>sA~ho*lVO9dF#D(@45L_plwnwrzgta@qH5q`$|s4`$Kv!3&BL z;{OZVtFepKx4?PedtCN|@hlE7H;5M~vu-ZOCam+({p|$yeNEd0`mO5p)o{Rr4?d`I zf`=Qg#Oa9#p!?x~^a;qZ{Ykt(xQ%#V!xu;nz=juoz$@_|V_fH322|GKxN_u;>#`3g zu;0?}z#lw-T*3uxQ{LjYMQ)P=$VrLu#B&@E>^F~d9{XI6?V1zdBi+w>NICq#Gr`l8 zNuD|e|JeWO3sJ|8evYxrQ;0Pc$z#t$id0OD%&{s8SQ4uBWH|MzzJY!4PYQcneSg{>7xsx$8sA_X>??uk)xz;A zm2KFx!Pur8t{`y?yo2#^u&=VUw*57p#^)$Tpkvrh#=mU>Z7biXF?R6`jI~*->r9vf z_CF?guf_?^7YEQMp?mZE!X|o~M4vu$1yUzc2L93U*!I}{lp!0)3}Zs#Sd0~BE#j09 zuHiTW+)%b13n*Am8z3+m_BXaXcm&_!6NDXr{J;rm1Ay)6cn9ai1h^O5aW3|U)^yRk z+J6B594lOdZW>iP!#Utz^3)OdulxVh9_9eGm3dG5j++Rl#=kfKy-!=xHsA&fM?X)D z`-}FB*uU*${Cu6`arU;G8OI%Cek1;eE=3PS!nQK@OP=vRe0^eoqhsO>_6%(94f4=oEHUQXw1Cj?)|KlHU9Q;!j-yjGd z0GWUbz&?CnJD|u7*P%ZVJuogmj(yq&Tq0YX3${sMGs@A^VWWd9Ki(3&1+t+$+lBn#2Hou5i!w!A48ZrLvBR|H+q@{tx@#I+i+6LI>kF znGdLIy?exL@VgiX8TuZb?}Tly_J;q#J)DodM;URz?Dvi_fYfiMz&|zsF#tH0JY)ah ze^{6&4$uc+O@lW*EwT?_uZ%U z0~QLq^jXNSkS&oM0Or9on0Hy(rrl#4q25((N&BzSxr_k>)5Hhdx5&HbH^@%v7I-3X zAM*-Yl0)#8xJ-9r0>{A%@P%XgvJt{A!2ZNvAx0RSlH7rP3rFO= zkWGHixydcu8(bitiVJiE{tuA<-x?a;OC34}qW2^3r%hDv)Q|0cZAV+hcs<+AI>-85 zbwxX|O>!T9$rJzh0}&S!9&~;txRsPP0N4f_;GeO5v>RC0GpbD6%CY_F2W0&J>ixvO zc}4GFZrA;>pY4mlQM%tU{Qo)dPyC<$w52+)C3)^uyqfQp@jVEC7eZtB`s7peP^N8v zzg*+g$a@sm>K8Y1jMR%R(>oJi%EWJbbzV#F4c}3Y?MYkosh$UP&p~8T^4<>~;7C1p zpX#|!`;?LVd*Y|6Cy%A3{gmJueuL~Xe)_=I9aPyzeQ-gnPtiH{Kk+}= zM|&gQFaA`$ATQWV83*vUA&@QVj9l$R+hD6v=60j)N`lYLpWs~eh>e$c2Xhev2%Qi1 z%+o3dqv&b$zI8r!zvP(qW&95b>_<#cV|Mf%%3p+k>_OuHycbxay}2K@I6B&PjGhr( zAGQ6c8Sy~jkAM7Q%>jC`dO_=ezVy;d)k`nGRK5K2%g$e9Unz5!m$EqLV*nX@pWV;?4PG#UX+ugB0;V$h0e1qhG z^e5650H6Ghld+7j77KOwK^5I6~0W;^t`=3UH#&r9?$py`Kzwh3{ z0g{gijm2UdAGjy2)wX#-7=;6f4W6pRfUl5e>PY5!p%nV_Og}Lbl+E zke>uI$yT!6j^ogMn!$8;{gMb?NNu#N0-j52H*xP-(tQ^u2LQr_9emf$?e zN=MlL;2$nS?}qP>-k1HaL|wW*Rkzev)R8b`-sL!LqIxrqw6EO#JL(^2j11e1cgNp{ z$LVjw2cT~|^nTa^)(w&w?Edi2;2-WCen3n2PW-d}Ps9NgyVWyJPOh5~v%r)1A8Q$6 zOLGnE8T&ZOZO7>Oc^BRdTR^eU>g&>p{53J~uYG%84wx_e#(1E_KQTY-0LBM+{Ghns zm=~ThKET`pYy+=DCc7bgL)E?T$hqX$2k?bs1UetA+Gc>W!E2F2uv_C_@`O)lVk&LQVF;2k;Ty8N!mB|6>_@qh3yYz6*Rhj0KG%s3!* zLE`2Y zK*okVA5fSK{n5sM3%L{i$t`ob2K$La+LQKUA0Gfe75q{Lev#Kap19^Ww6%XzYh2Xq4%TzUphf@Li>RKSl3ux}2p`}Z;2Y+6A-2)+;Sb;wL_*G*$vOheBZoQ$ z78A$bFFHT5N}dUQKhtmWc;>_pkVWo=?5A(QZ}S?h;E;~Nzv6v+tzV@N@s~-wBRLqv z4r)=Cs&CtJ;zsxw+~eoN+d1Bcem?B{#6LOv;SVr&hA|}Y1U^DwpSq3|@fB}N?>iPC z{PS$!K5T#eUU309fN_0{Ync`QfAcqf56;5|$akZyGv33tc}&kh`=U3NY0P|+18Oq( zSGY0V25NEuF~NnUU%+$x)fV78eFE%&EgFx2jKB-vp5wGXT)^7n)&<~6@&E_WZ@~J* z_!1c#$oL{M82H48luf98Y!KuHu7Nj_56EfH)ceU5;Fv_ab3AdMIc1!S9B^%H0nP^l z9P|CfR~=>l3;*EDaer(8r>G<9#k#psSE0*8|AQUcjy6TN(-+>>@kQSY|IC-A+~e*g z57>a{|7PMofo;{be2|C*;ujF}4O_r50owu6|H3u#zuEAQ|4*!!IRL~lq7OK1Mr;k* zleW$FWh?-)M8DoVjeEKDvdgM(eB&GS^?`q`510bCG1zHCFaiDkmv&@NA#fb-lRsCL6P0Qa;RZAr>wY^Q&aZA=;G zCMSSxw(~eS@}B!_!!uyNE*Hm?>voR+-@t#y+p&>(PwFh+oi?EUvDb6_Pja;VJjTkiVlGLoy-HF`?3GQy~n^9_spHaCM$gbvinbRZ9SI3`tVzop0j-5NJoyVWxvSW4}SM5CE#OgTha~|pV6Hl&AI{8$ccS5z}IGxY$ zogeS>PB{4#|7Pb2CsjN2yAw}2HLl_7a4jWmbNslHPCE6p>SQIC9e{;nl< zpT3Q>)N=&Db;S5Ihe6xK09Y>s`=7WVW#g(_)&xXn#CQSO+R_Qkfd;>k!WKwg2ssP+ zOW8}`02u|#v>W4DOzU^>4-%|Myxq%#qWvSQiI7P5s3>2JZ3u z;1ajD?uAWG-EvWWmlJD4vZ&Usr@eXE%d9Ndc`j>-bd%DtXsT9c}sQK ztu+<9wN7D|5^ENZX}l7COs}>Fm2;dm&3kl>e(@6Jz5V(YhUTXZi0gVa-;sUi+U_aG zDeD{1`K63&jff)$T8eGycm3i>%7#>b{dzajfZCOFTrU5LyrnoQV|4J}su6kDCSLYr9g3;e4+;ed^lY3=VxZQ}>P5&kx@WPtt68pDh( zM=o-1VXhmB+)>`_FL3M`eCk-pW#AKxx-HAGdhCk!lM6TpJk#EkA>+ti$a=eV^4_lxLlSnU?+iSm!J8Z#}LyvK=6P0PnFD zp6Zjea>NPjvwnqbK4IHy;Hdq>7byB)a*@0f<2djU#23T@C9fVIRGzU*5aitBLw^r*m$QReSYbG_PSBdl2t>371 zL^NlhY_HXP->ut@&^~LAt(uND_D-ZO9?zrpsSJ!v0OVxD#YXU2Sq0kRK9Bd0yW zm+#j1f7b{LbK#%$O!!V+wmG^YanD#b_GxR5XL}Qy%yv)PgnB^+TtCXedW=bAj6U;s zWs?-1qArP7V5h9ccM(U>PYM3v3u9dv2lwC;n_!i&zgAcr6V};Zzj3oM%s$894JWPd!wq@Ya)vDqGH4d*btDSSfZ$CVvLGl!4^Q&5ETmwC>RT(QNThK z1qB79hyrr{_iwFdopUsK-#7p7`mW@A@j2(3Yi7^Po;}a(x$jkG_TDL~pO!8@5bg=7 zdafutJEtrsCs*%Aty9Jx;Q0IRIAGK@*Ob+&S=03WG~M%5;Tf6F{vcxa0m!eWd(Zv% zWA_Q#8iH&?=84G>###9DIfWjHed)SAeBk%X&MfIR136G9ij zpBKmEQIEjAj^pqFkWc#wF4!ydf8k%|QqyhN(_jqWUn|X1<~baI9e~U;8^FfKo@qH@h|MNcaizQT<|&M9MFXOotPtW-eVqpnw2&Qxyam(ETfhf;Mp$^S9MBgCV3~R?#R09`xQ<|4pnNc$ zmXYb0SK4(2@riIvU2qQOTPY0>v`s}fa4w(>yrTmk|Jg%D0P_T%7xr|zeDS>hBSqgw z{@3+@5ySO;fdmI+A=8CbWIqAle)s@P571Z<8z0~gNcaTci*gRoSTnEO7a;EEKIncD zSVMAK)O7*!z%w{CeO~F{dt%BWP)-_uf877zg{VUud_X$nl+t?z{-wTXOI(x9zR$*S zPulk1CuA-h;5q0wc68aG3T8u?C3#kL-8(?>vwp9zZt$4p1}hyf0&Qxg%w!mepQzD!2w`60{4#fedb-@Kgzhj<^$4wj5$CZ z%9;0y_X_-Lze@wjrLXb(n|__pwb27Y=f+=Nfq&;f#)om>9#}AbhNInAWA{w=1niUM z{)TNw9QFe-fw2aMguoilng;f;MM#t9db_>_D8Jr+>;D)2*%t=J4g2bA%meU`zlrOS z<~P7?aV{FvzffoJeX zntJ##@NuFe;48o$Cyu)1vGjq~YR7bb>F%7HB|SY&HbLkHl%YGAEnr;WX=Ho`c7@s{ zAm=T});10R>$+~H>Vz(kCSE`XAch051xAki*L;F}hv3j!hm@rVuc8091#{v8^Z@XW zZxFq}I6&hab^#m^&k%AhYX|VP*#@ddI(qk_n5&u}JN5?qU-JJ5;rvVFJ(#z>HVHcb{g%KSkKfIh zG9I>vU-!T`S7RHv_jLg00OxMuNF0t{XZoDd$X9#UE%%%ICH&jFF?0+bgKt}LU6cKl zWiWFg;r|!@+x`#he@C(ZSN?nKNS-0F@77pH5NP1y5A?2+%!fx3&0!n0Ad1betxPQ;yFf^nUOkwtw(KrtAws zb{607&9HHYAL~f2j~z26+yVcvPrC9Rp^@?%)YkV1z&HK?bbs)isdtoA)(4`THG;rC zT);W_1(-X?5#|(g&3%mGQ{}~bNx46i!wW~tM+DwOhNDx$8SsC^%;m~F5c&cf5VD?f z-g^)nKpFE>VTYaJn-~M;F)_OjVZ|;Hrko}AYznj2##JD&H(D&UY10T&a_TUGd zpZgQTO^?+=^b%B2r*kK6|UK3x7s3?`As!2!q|WDnPb3(8~h75jR@|Bf9yY%a!L zme@bDeED+u{*Scp%!7UA3~NiI>AUyeWe5Fd{TZJGbIP7iSDy7H!4t+!hI_XMf(y`_ zf-Av4G90WMZ;H?034H13)A$8j3G-l(JoFdyA1a0q%*SV#M=rS?5csbgQx6VknFwGU zT>$Qh0Orx-Y2VV=@(I~**Hk`oK1=xK{M=mm2s5Qq7%#|fP#L&~1M;;d0M7FCevxc_ zYXsf?ntyR!U_>PjK<=aaI~TAwAjS3w!Ud#hoAp3+0x<7m?*2e@1>^|x$70>f3Qnld zMFZb4KkZ%%w{X2-9(zCLfbz*>u7@1}-ff<#AI$l{L12J9g&nM&{ci`q`d?R=U(MW$ zy+8Q+uxA+;#)Pp5KIFICn!WA;K0wCbeK6tzVV!+HU^`v!$ny8!`&uCBIIc*u27sN0 zj)6ZAS;##WhJ+ceoo4$)IClKQ2j~T?>+&ul?6)mjw)|84fBWsXcAr_3Vooq`knj8! z?mx%Ye_cC#7tAqqf#$+KWd!()x~yr@H(NjCT>7MApF8jd`h;@>*v=4cT;^*Y+x%jV zc>wpWSF!I!Yj)_|=n&vKp)a5#AfMp@))<2ir~`gYhkz?o4rVii-H`R*ojTd_3%~*7 zV<%9LwCn#a^T|W@qvxYrG3FUszX$L6d3m1JabaPBt>dv)k3N1_?b>BvJO2K=b%Bv1 zhL;_#_b1^G3haaV9N|4*?=!;|KnEZo|8ui79?F9QxF`4#xGwj`=YromFpUmL9p;bS zAKfo>%)mJJ-eg!JrXKeWUf}-Od|*x_0&^U_pE3e-EbIU~Pvy!({x{nT@LxyrY6bHJ z{QEu_>|Gxhv->qJjEUohal!|JeJq*Iyw?3H*ZYNk@C^qb``HInj(6p^)OYguwuSEx z!uKZpD42bqdxoCIJ&a)Y0GXP=KhK?W|I98>K04mAWy}64{>fxLO83atrFl;|^Tpm* zt~B4>J4$kND+kGY1= zkvWsF6>YvTmNA~>ljeQq!3p?T!q-B~eh0A4+6cAn9 z&-uQ-<7+8g4vn2Os=Jzj@a;x;}w>fgFbavJx0XPXe2VX^-TUS6mSk zP`(E_bg{~|z|bLs%Z}7H6f+FVvL}RD?0|I52U{Ni!)g=#fOP}X<||Nrvk5fM&<$J% zfNPzf=_7qZXQNNt3-}2Rpp5y#Z(WbtX+5ZXG+06_Y_%`F8x&Zpltf|>HmQLumLC-J!+KgApyho z-Wg%HnF97Davr=l)$>gFo9$TKz%lPG582OrKU%n_zi=M;rZZ>`!a+6;nj7Y`P)t7= zD*}Aras%5HdkP(a^#|7%_?;Ywzcto5h@As8k0qDG#-J{C1OeG@7*<}C6PukO-2gp< zhJof$YO5Yg@>_;3Nuxr<@ zvcmj4`GcC36-$@MmaU&5JuzMQ2Iu$&h+}Oa^Z{E7(A)^$AbvsS3$_CHg1(uI2j8Zz zF=vrIj6ZXVac1mIZ?`hana{!9#sP^u?pa`-dch0KO>6^V@%~m}zsX*J|3f50KE`e> zUjsl7NZ_8i%e-W)2=FN5(@gUpekEoM;RM$B!9RAs@At#td}Mt$~c{k)v~_>Dg=^J70R&X4+)k4b<2`RCToXNl{OS2-{)ALzYg z-+%wT-G{(^>s7@zx}VK)~uHJ_l^SV!oS9b5V(&x#)o6aJmclOYtQ-0p6B~@_yOU0 z)Wa|F%y*zAxxB&cveeRpB3u(-e`yrng|Ik6<1$1o6xku=2^|dem zqoqr2iv8RMWez6){(U3x&&EG^fL=_S`e)fzN0!TeYysqdQ9-`Nq5Gr%!w1+0_z2(t zlMl*6FF@B1eV_G#Y`qU1+W`FM9AdcaI@OzZwU;2S@sm2!k z+t>>4*!<`K$bUG1=YG-s!F_HnYtQ(#+26tYVUX?a<3YFM{>FU)^XM1wGCC-{8=OvT zzI@#ic;PTTdv^M1r)qE1CTDr%TdU+ zbcXUM!$yDu+-KOv`~t{+<_b0$x2U4gmWRbDr`y2s=ma1^7QmzMuE85nbkk)lBpMn+-B+;Bcki$_vG>sp+y+1| z5U1-tlW#mA`>tk<8f9Zfk4|!c^qqejrvHCjD{jo7i+>}3OGCqW;678bAxi=5gZaQc zm?w=cz+4H;WBVifeP1DSi82eh1fj=Y$C?Qxd4AL{xIhN z?0m)@-74@+8r;Y8f5?1n1nwhT&HY2B8|D+(H~+HIoNHy~r&1jqb8q1Sd+(HD&d2ZL z955=Tn#KPwV&RwACvL{4pMGk3U#jE+avi-Me_)>ULgWL_isb5jL3ugZChtkdS|PT8 z`v}DYnitpr%pG(AmmT3qHs!}H(a2L$uj1o)QO-^P9b*U5eD)W^%m z4NgE`VqAS}*(0EPr27FE;M3z?kxoa~lRk%!k8zOZ7 zobJz$RB*tVr=MQ-(MKQsga?ub|Jf1Pym@okamRKpYoq&>t8ba(<1-FWI@|n!@(XER zgf4(RP+DAMyue-(D^odgAHC0P0GmHPk9;n98hOCH!X`l9r;I$aEfq6InOn>o~_Ltw8l>-)T0j&*@&o_@M)h5Y_}=YoBL1fCb>J^vAtj$`tE9x9hb z8eey`74_pd>O?;AKf5;h|EzB1^P*1Fi#Y21{5bL>rk~#>e)IQYtw|D=jIq_0$~UJtu*EH~`%~0@#NW$OrRq0P^31ze4~$ z!S21}Jo+VT0PH6=T~jd_C(Zt$&=tWnbCLBz^Hb^A^Z_uhwBcOXc3;0g3x>{_qjwfy zcZR+jIs$WE_+KjQ*Vqg2U!_U2qh}s1S+CeI5BAv))I>UfuO<6lDRh4Sj%|hS1U|ry zf>XgRSVi{31;zowzxe_+{$SpN>@}sS2N$p(sBK&PE!Y;EUm^cZKakwFy}8QgzMyYd zUDfB_!SCg|py>l@TfEZX$og8FXHQOZvoFyznSU8+(laGfu&0odVzUG+at9>u?In5ro!u)*Y>6+4Snd7`0 zGt$gW!$V^JSH=c)@}(z}hi|*6pde8j~_;1?0`D5%EXRjHU2mj^+P#T>d{RdxQ>{~$>z#d?%C=cF5mVxOPK;E#wsUiRJyae&=tT@$?z z-3~cmb3pf+eaZM88_V}}R6~6aRL9M}&FOFp8^{4>pL|*VIOwd$fK7W5IC?t=0JRnbPYx zX1!K*38eAe5^{xGu*I4UZKP-&H}v$jN^?wTo5H=pzbiQ)8y{@L8SpH)jsQ2%CbAy; zAG`;L(k}L4zW4_&gS+5P<34eM=>+J4y8rw(LfmK80^maCg{=*04wxOU`s@>c7pTj8 zb-9nu59al|HD}NbnA6Sy=(-i$z{&a*KhK{ha$B2|u8@@}U%djdy+iv_;SD_|LwJ_nA)H4jmcD@AKs~T-x)$x8yyF%yHC*90~l9iKwv-SK+Fl(6h0@DY4T@VhSyh)QL&+ZM_=QrqG6*r}5FD5(gv!B_?iUyc9K?M#o6xW>9#TE#J{UDTCoszVN4K|F^B+E7{vf-+ zdy4ia8CMAZY067cf7`$v3MTIo$K<8UoSI5c-eq z;@AbFl-XQP{5IoCjRn7tG~*T=61b0;YgYt*JL_R!!Z`v?V81;fP5s6P57x}S5$3@@ zIy$%o=g4E^H5lhNg7fSw^m4~+cDAS6l@?n$cn0^SrNX{!0mF8pZs44l_T6rgEnwF} zC%`U}J)l0p1su~qY=Gb<^a1WK?-RvdU@WkK^7UL|><0wL$O?2v#*6^=@d1Em*AIM; z0C+cBRdd&E1h2#VLVs<>{1)cfXJ+#}!3Dzqqk9Yf^$g18=>ORK?EQfQumQk-VBT;a zK0(tB9QV}6K45IXf12(I>-L%Ac5n-iq6gSIp7Nao&>b}AH7~hW)Pn;ub*~BV009mt zkF6ecy*~Co{1CQX+-GoBao-(h%wgs~`w)=NU=EyuY4AZz027pxXM7=Sw-FD(1uplg z3%+TCbCCVQf8a^iz?U6*0hpxB^0CjRvCEX+F*b_M!DnJ+e1!MA0Dwbbq@9I zw5U~2Y?SLg9DfYx$@cBdxNDja?E>(u?xU?Z~!<*M=_Yb^*Qt+yDRjy#@ab8Z_v}^FQFfwZ;LgqYJPeXgnaivo1&=A693YtsmzsY-xC@z&&5)(}fL)K=2kZ785IfH~0KBvIVmbkQ zuH#H`J2qI@0u^|7f1<4;(1rzU0rYRye`Ir5zzzVr{;U~T2TRQ5v{d$$!z;`YcmSJM zGCzTJ(#T41?OfpeL4AB=_|vdu!5r5DC*`;|?s0wzo@lE+a&2@G!?PW`USPU`j=?_u zFq7ex+5q>Q^IziMFt0iZUV>|AgZ47?8`1mAc}TeDcTzb7~#7kMvi<7@mm{tM&}AdlaJj4zgMQK)eP>jbbKq2f48{T1IR;O7J1rVj|$ z)HVG`X|sbgKTM`DSA=u-4~YAPYjBTEU^=79sYk#DU=Eo-5It6yH@%fxf z9^F^Z{|w(-@ZYdulVfcCFJ!*(&srcj=iOG#HFlNv1^AkP@T0K{xsRWKu|fx6e27hc zGrqz;0lZsKd(H*20noY8X%jp`Y&wLln=L*@A4rv)PX|-z7Rt}U_7l&;E#?D2S5Q7W z0bG)$b4bGjZfCgMWS)V=-~%|}Cuw0E-5!|_p5X#?`_#6~BYa-y;FABsm0?x*1K+fR zOa(8bS9ot(B z?u&}@%dwv?u0Xy!2XN1_Y@MN0wgS3+uJrmGbbJf4?Ipw8wJVj+(D?va&;3L$6WG&( zZ;${FAlF^~^SqG1XA~UsjuQ3&I{&->IIvGX`aeEXe2Bho;Csu-gA2er_^-?Xv(K8}`{-@UQpiq=NNkJOd~W;Qcj@fBf^>J8g15Y=8WMuKU9c_yWN@_%@$_(!`E) z$2@vI{zB@Y_h;yyhCF9Hk@>No2VWz254n$yPhEH+TbL)dGWjHkeZA1}kBlgiF3G%s z8^Atm3(PAp$r=PQ1Y8DAQ-nvJO9I!#E~|;r8CsZL3Ld~O_;7ykdWL1;6ilHvTf5eV z+9L+*3g8i(d413OY1*-F@?Gt@?(Z?@nQoA13!H*Kvq6+c8U6{lDr9r;27O9ZKK-L@ z>~@#a=^s)MJM!P&BO=*v<-&fR&Y=w4mXs8$4_YV1PEcN{jID9S18j}Ek}XiLUcD}R3;y*x z>Im;&vj+eUK<@|tq4T>v;QIl2o*ewM2I$WT`P!)K;u=SdC$b-`n=BVkgKzwQj3H_F z5jdvd5aR;y%KgxBnz)?MN?bu+_ycIK+&`##;6L^yqX!hoZY(O&o-xS>^b5EF`N2Gj zXOPi_-A6z<&$eT;%8x881@=A7T*24Rd~x{=z5|=2O@|g94Ce`%YkIxvJ9kKL6+W>8 zkiXzD+I0L?*!zBM);Sz|T$8kOf^&%Rh%jonreCI)xSj$>`P!LZ4{YP#3+#togKf|G znHktZ-WJCh>7K@qS1cJ$d)61}0LXmSaFFBK8ZXje>&G!=`O*bU)??p`5AxCV6=&-h zTqE1Tb)N8Pa$fhVn7s+QKj;AP2K6Y13)+>I8h3+v{CU^|)Iq;vec$Z?ez*G#&ulqrE>m=3AOAWcQn1ptN&9LjM;wk@4^V z`K^R~0$6vv%RWbcx1jwT%Fob!3*HDmAZA?_oq;s`fL{PEK+kX+K-keSTwolP$ZsP# z3n#EwAzkwyxrD3)*Bl2PIgYf=DRBmVIP%~RupPdA@CR0cBj6Uw!7yc!PfR{@5tWZR8_{2mP3BXs zdSI7&==KE03AvAc&oSjW#s$s^9GA%7-?38%ljVgb`>CfrQV9+~2a90&IyU((PH5Mz zouxU>77xJT;2fPFz5wre`22KC8jk4HQP)y^@CsLe>#z$LPh>pfi)<&KJyYlgp$p&- z0Q;;7fPL~wb1uiW_AfaWc(>>ObxgbBmoJ6?ntKuc*#kJex%U4c_X+0b6ZUyOZ4=uE z!1ICnCP=~;$a4Yg0Rpe=2}B=2?+4Gwf6AK6?@t_Z8!jMFhx_cD;P`K;JbaI|hYpYm zu7wG(ADob(d(V4y`7R%Ue6WwM5P8@ZT#sPu1CoQ`hje`bf1>k&`O^(s;up<(%tl^P%?r`6O+2}fl_RwPt_qslPplx(?FpKYl>p1q=d%?KDm*8D%|A};#eh+O0 z=D|H}z)h5qM#kre3$kTHAm7pTi?r4UCxG{3k2|KUSQssoE!IJPeADk$kGfzVoaZXe zN#MOy9FVWy4#qhrS9r$v6E*<;o}wcQB9c?|$O&17Zgt@6ib?oxr^1CG-QO-`bn7&u`KDa32z}o62Wsr_17v9KAfGw6g^tNH-q6^C`;wXOM)(h~6)azUF&S<;fc(npAIHpb<~tY# zM^T3uJdj5WuBi*gz_#;A1n%_=Iz4 z2mEHLJ~6li$5vlh2lvQq@;r8b8T?`RR~@d2pN=-2Lwx;?ez1>&w)w5Jou##Yj@xVB zPl4F!0v z@(rr2q@=`f?mU3pXPrpb!!MwFOqzF-q7Q&~aE;B6UEq3wI9_waeTI@Hnq$7MfUW0t zf2;+#?k}HJLLaE?3m}dD2p^yeu=b~SKTh1+@ZYj!%5l6$-rg|@&ZPr^b1?4qfbR{8 zbpY%D(($hFz&~pM;M?%8baVUw_-Vw?ZWD-eg-dwAN99P-}$tio%W@ZDDT zmv`ghi{#rE>{S5sS(*o&$2rJ(9dmh|)#VTr&#L~K!P zhg8!OoGa22m`fFAz+K=4``56d-$z`b3sm47+rxDS`K;9r=LdevImh&bI7jRA>KB-H z%yYh<<9Xhm=Q$_1T=sL&A>bt1MvviI;5YgQ#}VKFm;GR0eM1+JU%y2AcL@0Yz&rS6 zjB=$XfP3%_&dFo^Ozum+$W?x}@(YwM6-VSHbbMrbp)g-8Ov3^BvisqIfP9)d%a~-`D>td;)MKW#|Oh2(JGl|4IAWBA6$b{+__H;aO?$-9~W=xCZ~i zy?xt89MDGZ!zACnbD=umK3(%5P4^sbFy0Wiz%}p>JVDy+1nmzKC%{Rz)?io{)~uee zB0bgg1jDJ@667Hd@f*Ms#P9?7hcn6p=O~|Ya1%C2x^aVKv&!HS>eG(#kJZzj9%09L zL1o~V08WuV7IaJ>$fq872E%X)GMr=Drkp(bMm_X)FblpZgF{GzZ|a2IfFGZB=u`2} z@XvL@dy$^~$wdCFnE)ri z1>hb1pE~rzgZu-!&*^-#Li3C?enjF_(<9Jp-5vqs$Q)w&gigSI1LivN5DbF_^3Wdw zv!N?gj{P?|=CN@D=Z1T92H`LQToN2Wp799laS2{9f2H#MT*p5C4C-Mgd!PJ${9uxN z);a8&N*f2j0|_nw=kNhsg8atrrwptcj`@u$Lw`rcb6pz)lmD`bbPehu*R2j5p>e|= z%hI_7^!NhFdDHRfBf7ruZ?fKa04{(N^2^!@``jDuIh;@=yqCxa*!kFFEgz1cUXH#; z0`>#{h2jXlM~qF7rEeV&!v&?{5B$EN>$4_E8g76Wuou8B`ak{w*8{Kzcz!^0L-NU= z4`N-w1GWIVKYj*u1^9yfL9zkX2`bh0M*KHx-h3S2mGS3*kooTavv+nIA?mK*t zAwK|ezqRr$=zPP!@=V5KA1IG+Vc;{sPO0Dq>&o9Qz&`(vJUpFXJ94L>}iyecI=k^;CWjvK=`Op3^lhg{=1ry?gNB;AzDY$?&Kzk-g$L0sX517Ec?E@6HvDM)M=CZB-CwKs?fo3`KvTON$_JKAdvu2k<0 zD3Sjj-Hia&i{S&^#qu3?GTdhi?`9*2E6@pY6~hPk z1YHks9T6MAbO2#G{DR2(${Yax$@7rdA1ED`FacnWsE#y;|?ho6#uf${VV z2!{X5P9!-Q0Hd&TrO{20WR zPSlBV
2UJkZtlNc@l?_4MF57*J3H09G@`bi(bJlLm;J+z__uPEI1qY}f34H*Y z;O86wu5Gh+vFZ}Xt`@lRtzwrB&%k%`+!MkyS@Q+W>a8I7fIfhfHL*N?Npu{2jDMzQoUx0nV zx+8h9*8seS+^>vzlieyaJ}}%Tu%E~Sua)aqJ?ZAbAz+Na+8FC!>~C_}p1`6o2gYcJ zbEr$7(19JZlJ$u;I0nC5gO~tr!9R6qC-P~-^Z}J~UbIWhIpCW**j~h(8+G8)jKnoP zU;XBoIwt$|P5{aBOyM(MI0eIC+;9)Rh2;o2dGa+RFz<37-+}W3$AqF{*8#!~fD4MG zFTne(9k6Bq=2 z<_j$h;h);A) zzDtZ=fG?777Z)g>bppN*huvQ)Zm{PCbq}xwI1W8Oc))yonitfA58}B&(*q>SIHr!? zo%HLy9sm43>Gz9R2f*)#4j+4ciS2#B34I^DqZg1L&;RlrN#gk{@lpCfE z&u|4;rT^adBIzWo$MHM(ZKUzdf$Mbi0%4zg_@G$%92*Dd9>TxKe{c`}k@sM|K(ahb z_YNKieE{1ZKFAf;oh#7)W$()#KzGP5P`S=AeV?{+2-+K@a&&_%eKQ$-AV=5a8|JYt z0M1ztV1FPSfb6$50mbYMARyDhx7i1(2N#r@?tssbXT&r=(GOIIbY@x_-}^Y=mwfz65jw z_yF#ReE`_&U<}`0=mV+3Fmef>WQs7Ik;VQ1bO!7L=L2w>md?7M-v6k)-~!Ge4gQf; zv>W{5vc3ZQa1dNWKGzQ1(;u*gT!kar2;0U_>MIy>omu!$AAW-WL_O-lad1e;UgHN{ zkN&s~qIGV~S?bac@J$`$ZSacWUww-_uE(`$llu6|=?{G{e$n-~X7B`gaedA!1>3?a zp(A2;ca_Ll+>1Q}ET8U4X1tp7{fn4jGHS4qn_3z&fDT2ILoL-5N}ySEGOD{RF4T zd$%XRsr-j_jGm9}uQ@ILusr3{M&OJ*(s7J#0Pc}DA@7-o-~=9_KK-K}ZGs!ee8Q&C zyo&3i>Tj(+cU2z5g5rag|m%rVR{?SXYLPM{p08~!u606Tz~z96%`Pril{ zZBdTh-By?&fPe5ExHsECd2l54;3cpQ?!j=n?gs&WWPHIkGCX)7Lpm3sf&)l9{zJxl z3=cTQ!FhfWYXkIe|80$*-Cry2|jUND;z*{7dZY)5}e`nDC4}~5-{#ug&l$2BCgQ6ZMD!2K$uJ4lz7{3{TT> zp7008!7`XNPEbGLfpqC*;5o0b*p40l>;-6V`OXjc9))M=0)*g#9J`OQ?UiSE=Nby! zU&fp9Vr?djTrN zyc<#PueI3v_Id(G8Jd&K0pF7VN1)H+mjh?WM9pvU0rlM0 zcRc~SA&#lfaRl;cMYjIeDGf=|3W+IK#qB^@6Qms9)KT!_lo(N05Tu_ z9vL5gLEgI*9N@NqJu|$A{erRszHd>d#UX)-Us9~KfRj?efn+K2znRwlTMMuF@OT~) zy`Ruh{y*NKNE&|%>#JU_=cSbf|2#iom{&XOt>pb2@QCXRiDv@P3Dh>YbH4FC0)Z>j z{nZyMS3TQ5gq+v>2AAjv$awt6=n1iI$b3dO0PC@i5ZnU4fFH1dodF)viD}QUmS{Wh zUpWRdt;A(uh`DO=S#7y|cTNCXhH=H5>)4cEU44wUC?{lsA@rHVah%67eSzE1>#>R8 zkEll(?a?-Q*c$YWJokM%7a=z}7yOuSRDELXsEfZ1e;mIh&P~BUvB`GuFMPudU>#kbw!wS8I3hx(@dwWXYHyL!1@H&; z^t%K9xo`-4K%NEH{lPkNKk$za5KaK^V482Ep!Z_~Am1&W-~iq?Y<@r40q6=!U;0bJ z{Lg=@F#U207)edt!yYKohJWpiM!x$qRC>3U_`sf*HeSFM&~t#ud)0vpcozsfKz(cj z)@0EYXp8Tu7{^GbFh3wZ2k{K+Ipo_~0OzT$+Xcu>m$z^Om=0aQdYA5yqpa&Fc3|a5T?l=Fxz_X$n5~$(WZW+r|rQW;M)S4iv_HwXum2gz?R z7BW2W$1%9}eCGtOC#)J?!K{Z&%~R$NI0RdPY0d>-*aMsk4#GUlVfp;eu=F#`Tb^`OZms%Xa_jmaICiTHSYb?|^e}M9B@1@%19T_~2hTVoefDT}5 z3*ZgB;}bOB1Mlb1+P#j!4)`Mt7O@vd6Tl<-1N#XA&w>Al&5i~;oGaX$JuPfe9(J^G z0`gdO$V(OOxIWkA7(JBs{Tgmda6bG(o1{56&bMpmnEKXUf@?@e0N401D|o?mh`_jE zIq}=UBG;jQMY+rH;28RdE|ezO4wl$EV?I({)6W+dDh-z)cd;Yj1Jga=B3+}h{O6dy zrDw1YSGs|49rz~(_xSt3G{<1NSaLs2^}&5cB9NwC%4rLqe}_)TSRd&heeI-UvjNm* zv3vp8{OJ7X`t1AV-GQarN9fo`*6TM)#uNOVzW-cl1h=*QUq2{?8sHo`v8X>c+XmmD3^X$ukTKo8@&9?N!D<{iYla0lSsU zoCBPX=zo-9w*)`9?&5p|=7WDI$A4(}5dUB+V>6>SVDHmU>cSt$(9ki!d~gl1rH!9x zn{`58M__%$u%Ey*xJ1uSOLtp`m~!&Ld-x5&GQNA-A(+2Vcqbp+bIdv5J#>EhOX$$C zv*{)L=CJe2V~r2{{n7QY0nh_@XE3&Z=mF^T`25?6D|qfN)&cGN!qNr6JzO9=V2^zd zSv;Y43msKy>L-(l5fBHo!QMxAlCA#_9MDSg-E4f}nzdAL&$lnl9#Ed8(HoSuJ(U`J zYyii;>Z(5Hu}_2dZ{Z&R|LhZD9U)Eg8+!nM1UP4pH)(K>Uy?LB0DC_<&+Q5Ejn0XH zEl|!03EdtX8ot4}0_Ds%>e_ia&J{nPZ;*$5&Na=h;22$5$6ycr0L}7@Q#0g zeA)o>#Mm(S`r!mi%dSAyV*}W4QD4h(ue96y==IVY5&_>H`}_X^|H3!60HI_1_Gb6P z1NZ>RQ@n?Cp|IbkO^Xwffd8*Ypt;rrSsy`$OZVS{o{xUd_cL22z8?+l`G!sC1016Z z@SQE*r)_qC>;m3hYyJUo0N5wk^A~D6j@dteFMu@z^a5-U?lgK49LJNXHBgfqT-Bk2=q}LUUHv zC0!_goO6YjA?pphiYa5iIyfu+D@=0idVuo*=?Gja_zaF4=Hoku9Xnj+A??hxP*V#MI$BVA=D*EO>RFgVj-* zWAd4=M@z%9WW&O72J^@}um0bJuU-Z_ah`CJ1YpdNXqt0)hw8y83?@O*S2 z=}(Eifcqk3y~cyS(MJN;VT|})=vqAIM;d+K-UX=NU8KGv^V_#;Z$3WczOV7Q?H@89 zy&imb(zA)+A0Gg7Quct!ee(3|&*+%n$@qUB0li1^BEC0=Oc&<&w31$*Cj81kC~Jui z(DZ%2oh_USePT>7I#LNTmO<)f*cttk=&)}Wo2<9u$F&M|+i0u${1OL>e9Ni(fB~|h- z>QN3p4bNac5h$k*cC7QQUZP)>F>Yy{lOgOd=i``j4c96&8&}tN>^lC@@4*Jw#t%Z@ zdDb{|6Z#W@^MZ#$M?t@!4d(*d^y|5Az;O@u!F9-Gbn(a|2D7EYpZ6QRNcfLnT;P1* z9N=YO-s~Qo%e4sTHLS0o4|!lMPwNA+UuY= z9X$b`FLNOD17diCe8;)#3Z8Zw-*`ZE;1F;I_P{p21~|ldrvl5aXV9-GgG1Qwz`G`e zJGDibpD%qun2q;N1RrFY9OHRZ=OWI9M=T~?9)vli;Ujb!&LgHB^bR{;qNI6?W`Z}fKX58nsY z`Ifl(`ZcF4PaME8{yt>B$^ERXJ=6pLx!IYc{$XM#GydffXw~ZIll}YC;1$0fwm$gh z`>MjB_@PzHW~K*#`M|#E1PLzS+ZOh%Yw?JGqgvnMkblP328HE#J}Wo^U4V65cpX0k zbAfvk1qn=fZJH777kPg%$xkTJ_=U}o*>_B9^o(Yz&in5 zC1UJj_Qla=tf|_&2-TkB6|B0hKwpeA;1{)D%vz!P0rx~5;xu6!+;V>8M_c40w@r_r zUE>6|U*H`-N5`@c@Qr|V!#=;8->mVsJhNTU2jD;XOf>$CDVQhCJ>wXiKk(0<9{Xmf z@J=29xgXyfZ&y;hr$BsQet`V^i~r?`o~-y^jzDUQ7J_+;&(nota4osM2ib1lrWFUE z`==?+*Zby(Q-yP{r}clnyW5Hwe}J8<(zX5^$ zoY)5N0(&}<3!KY&a7WZ54Q}C(;0JJrUlZ}(&Q|$+Ye#KUh8_UE!7+if@r2F|8wPBXNBeM$ zW8U=~+GP))eG^c>%k&>%pS6DUeR#m^dX)wK1LyV~aozh4dM7EIP$Yj}2kZdl3IAs6 zx6^kG3JdafN~hSHzTf*_V)Nfs!2W=gRxM}d>w8nk{H%=BJ+9*m+u{PZ6STg^H}Uwc zu42A12~Py}-A9Nou%-ED`34rg0I0f8j*hGwj0y>IZrJ4j)7Ro)7$u zozJ%o_)aio^b_6>%=4Q`V+*)F;PT$q^Mrr=e#J!vwy($Z{haLgii!(-x~n0U?k3={oog~^K~3$5mQb*Fd1o%3ABX{5_O5a%>5N$7n_2%iQ%V^-?nc{ zzmvX&4nZBTPCe{;_{REYoWK~s3;z5be88~7Bh1=B_PAdf9{&@+S979KN}J{{r5oqg|+i*beMC21!4#qD!kGkgf(lyC*yTLgo_y(+jso263^+ANB_V$ z_WnzWUGVqMgk$Fuyfw{&UW8O0lDKp;DF=c#57d@k#50no_u!ccho6pR1&3kyj zIYo7l)yjuAnCIl9r$!y*JeZ}gcHN(0zB2wJ4To?J?U4tsWGFT+5VpwYns5ZyAy6;+ z0)LTb{Zef9vv6Ewe#5}0w?RGA%PZ9f!;Dq@F8pxDHT1y(wgUKfY|{_$Po2=MY`oMC zJj8F|m{1w>me$x4qtDBh*O+5BXwNaC-Q6Tu%~AD=WBBro&{ID|a=o~QCTCh$#B`eax#9wN`}49*4Dlt;{U1Mk+R za0gB)2WM~xJYf2b(&SSPE(w80e6H4}_=@wa4=S(F!&&cO-0=ype&Ko%ev9B1^5F^k zPG1b?3A|IE01s1-KwI1g#xJ-74knO?e~+R}gvd+dFVi}W2z?!R$> z^7+QC1z|lf5iJ4$dPo-^WX&PIQGF6!Ow#e=sz|H z@+f$MJn95z5QA|7vN8C>ImX-OcSXOsMw|>BhjV%U61{RLd=}>#rj@TXXv2gZC)%UUXxq}N2j7Hm!}MIkr2jUaQ|0_<177Lap}p|} z{i4s{#ri`3%iE{_9>6c>gC~9iSm!!m(8^RV@E%x?GG z_9n0Le{%$Ka?*44%++Yg_P+`1KfnVy`d&RDcmPb|>jS%$accQ0W4@sSGT#ILlm*to zH=KaJ04FeytR2o3E}~7txsHQ7Xb)@{c65H67xhE8piP_On#axs!VS0*UW+B0$g}>L zZQj-}#x+8R$gG4F!;9JrxyUhXfdT9U;}`XxJjw{vMHkMK?4~@*=vUPFY3%3HKhE`j zIJZTgD)`8+VPm2Cv>hA(2cg>|(~;jC(;j`m_du}zs+{Y``1*Ky-|}U5*jPxfqrcdP zoMYUj-)Z}F6604SZqj}|m8CCMd|PH_YG*yOap3>W`Iv0$KQ{t;x5y#lf@19r7%IK{ zaq+?1nj0IyG58_a91$+T8-ZiE0^1LL!SmQFgWO5Xr>u0z3%&)VXH8XCdZOkjb-cVF z(JseeoOwnXd=qGs`N%x=btQCNzc#T4wWT^>Ao6H~K64I%w#l>Ws4c^+a6>+Q;C#xc zi_e3!+roY=>x<+i{jhwcJ$Cy5Js5dU9&LjqxbLTR;~exB@rCMg4*G*(SI4CNT>b9E zHK+q$P{wbBi)fc~h{;FS0lSPZd4YXvOMPK1klh4uoGV?Pc4B>wJo$?JH|U;eJT~iE ze-{ToT~LrWGCMu}IDI?ji2vLSOPnpZ28Zy_V2 z?JDV^t0a?FD}1Il)~I}q(x0cNxBWu0erRS1h*D3v_%DxoNz7nq1tIm4KwGBGw zYlUwTVWZ+ry5?qr>U^v7wg}@}q^oY#@iv9+>c@9#>pNnCF#VnCm#4`SkJvFWWf63~ z&P})T)fbCtQ|)aNk8D-EH77G;i~9X7eegh^Qt6xbGcEONjo}7@uC-p*_)2p9EB&4? zg~N49uNCGAU&s%ly`62>=$X$o@?Cr;%&r!`SF7$S$#}v_U2~v6sD}1ba zOXS1*fO{eySSW6ID?cZDo{nG0%uIVqZ9J5ln{$W!jAJv?+g>7F{><#`^kS_cH%rR) ze>d8a*ZbKB?4x7CzTzPZO7BPL+O_L`ojP^eKZ2f9Ie@^NjUaudY6R7**0*opYI>jQ zL3)Pupau;ZR3}|Iknh3sD~==oA7l07AYC`nR+WRe4xwq4D%IlJ(I4XICvo(>@^4al z-w0)8W&03xOi2DEM<6)@$q`77Kyn0$z^Q04x`&E@H`zpC_m1+kn+IIDaI~DD7`L9kmrpk(un!Cbw7H zzuNINTQog1rB2`er(QSY`n#H@?SEj)dcVEC|Dnz6q>Op}S0%3>KX2a6r+s+afc%=v zXRN1xBqeC(0hkG)T!dXYu&MbIjH&p@js*K0k`Zsft}798uoEx(7V?b@^3~xi`s5Y(~h3oC*_XX zv1iAt9c!)KzO>KaUOT>gf6DGnYu2oJ{_FEbef8*{rk*is{jY~No;Y#h_!CzzU%q_F zt3Q16=f@|XymHjjJ6BcRx_J49yEjgoI(5@agMYa0?j!0}fBCSK&39kWcGGeFUtPE^ zZ`Pkp&5b}bz@x9@=L2L``;;mn02ik3C1^+v<3 z^ZQ@^%00Io(KWx(lGj#WlvV$=QS3?K)xV{7Ys%vEi()OJ5st)@@4`OnLv3 z=jxyO*dIpZH9EU%{u67rp4YzdgsBT}Te$Q4&u86t`s0fqp7HG9L1kqZpOs#3{EJgJ zyx4p5rw{l3^7(5z&%Wo}E9Xz>`S%}Ad-a`ZFV3j9czeUbZTF8HwrR=u`8}7`x^c>g zZndV)z3A*kj}6>@Ny!KK-50(1S;3we9ov1nVE(|leYOtXd1L0R$z`YPc%*Fo^YgFi zy=l`oODFc2xP4OTu&Ezk@>urShv#iwch2H#-W~hf`r~hS^SH9YvM+Yz-FR_cgLB8t zeY&K5`!|=?yz#Y*|t1BMfb>oz?^2a^*)p^r)tbe$Bk58Yt@wI+eUa@V~C!c&W z^}8M4P1{v#%4gPgt8^O583`h$3xGZGO&5taTn~lcXDx~+ow*wFMVxpuZK4rx%83_ zecE-n=H(}#&Z__ItOK4p>X8k)Jx@-Z|JNDozv`QHf6euCugIDB&eSgXPi`3T z+!OUa=+br3)E};S@rimfr%pYi-uU@Ldo6AK+KU}}e>iT9b->mMxdE<|Rs;(Sf(Eg<>uef5{#*3f1dRVWen-*L&ZR*9(Po0{7bAy`; z{?PBddP}-K`^qb4rLV1VP`z)z=+d>>Ewe7m>h$2EadUEhC_dwufkpN2tKIO!#TQ*P zX3Un)Z@zxv)xUl6zWXP2&E3CGt2SeD?<{R_+teQ}xqVLFofBKsSk&a;_0_MfI{1N8 zSI=&B_so~d7_Rszc?D_k{z1#KZx_xWaT_YEr@z~6D6KmbYjE2eBR&=mXa0T8%-372nQ_Bcha9+g`)21m zms~h(*tuKRG(Pv#j0Ufk%@{cA?iRD(UGnKAm+rZAP;Q@hrfq+Ad-X?8Dx19M(Z5}^ z^~0{uzWQqB0S8RzJbLW~RS$jYlwH|vyNO@BuRCGFAzdaEolv)Q#T(1k+{?t@^z`sfeeRmM{mPX69=jxCWy9II={x5S8dqn{U%&qSwcGpGn)UlR_cU&I^*xK~ z^ltd;*H54J&6D5UGW3{p>o%zS;vn~phodZXs!=bwL8*H>TYme%3i^!1q^ zPTARH<%?H+b9v#V%dY-mT$A0aHmv;H@9!G9Vl*1ir-K(}lzuk%l+4-NzCOQ0*}wz4 zZvS!oh|M=mow{>R=U#)GXH6P<+cWoH@bPEad52D@a_vdSrPf^iw_&$69W=3D#`%}7 zc=@bJlP7hp)ui32x1KWj<3Ap=blKwXcI{m7;_PGZ{=-3a_n$HSorN{C2QT=|yuuE3 zy6jt{UdwsCR(|(Vjf=-L-uLPB6HdM3hi5N6t=Wl(ZrgqB>dS9SX`6M%<*#nN`MQS( z|9;eUZ#=c8SGPs&#$5MCi&rQyF{Rh*l ztvcz%$rCTRCpV++oNYs=J-2ATRRtqAU3gNTo%vVS*8wZ*lsEp2q-&DWlO>*b>+Z9Vg=O}9R<5ypGJZz4og<#P>EXXOoiMNOd-p%v>C>f;_tu4;qH+OK2U(I~ynN$1S-fh&Mp3OOJc>UiEop=5jx2K=;%>z$9 zxv=`6Y8!ss_TiJ83J&`E$X+L1xb%wjotGbQ>SfQoR{xnt`rf+d){C~kG=F@~st1-_ zb9mbshc{Vt&eOFrPs(2K?9AD}?)SodZ?Aa$_sh0-?RwORY7b7Gw)&&}7oC6EjrTtJ z^vx4mSATd$oqpBl^qhLsb+Y~SHZ~QW&*TF0L4w(0F>q8oUwtMG_Cr@25ci?YF z^&0v5Yy13g-?q);>pph*(goY^dT~aeDb76d z_8YhKd?@XOHy%H$;H9f;x?TZ9<#c4 z-LVY|FTH3&$I;umE$V;CySX2=IO)$Fp4_nhix+3?8J+(?*|>9i5C5WU!3&qK=>Er* zD;gVY222`p!Viaa{PwUZeReKkqx1W3nN_V{ zyOA%wRPx>lXH1^{*PbsmocDRRPYVXs`0l&GS@mXqR5-s|+S*%2j%vE$cSHL=I;+dZ z$>ZKmJ9g>%t5RC6SUL35VGq1FK84(xVPz0wo@wsrZp-`@PnhVBDX-}o@Q>uJv% zbK<2Z&8l)v-aa3mnfvY^UOl!(&*%EqterP%=!{$T|I-T_x?j=kik!w>Z>n|Xi>J4ob7qzP-#4F8_40W;Umdz;#5td} z+c=|kr`;d^aqXh5wO3r7*5a;`+ke+}+k~ZEPU-N%bEl1cbm9$}cRqN@{sTu&81}%D zMRkta+P{9iy2}TBQo85#SH^t5IqRlrR}CBX$id5IpYp_%Z!dcCzQf+i+*Rv~rFTso zz3Eq5x^C~-YWbR5u3d9#>AQbfGW)%%7ynS&t9a~$r$=wO;+@mpA9C=bYdVaa-TlL| zzBv=i-s+qEW5?d3wl99F*V^584_YyTq3<#Hw2TI4{W!12rd?~Me7kvNql0fM`=oDC z(VQEq{^90*@4jis?}m0hrS|xn2j6n!Far|Mk zMxW5*y`dBCcyHjq^Pbo_d-P4I`*iD3?b@!{TiWb@?(n+HF8=Dpbz5gIez<$Xr;fb( z(wAFZd)0^$X{n2ry>QqIr){m@BKN-c#`d`F<3V)}IK1AfA!qh0?p`z?$dGzyI&Z8L+)v9UVW+K z=KdXb-SR^1HN*F<-Rg)}mVGtlq3PYG55H@l>1Xu({PF7T@3}5zpNoEVY}r+{A5AUZ z{oUrd;|kihKl_e$)n4w=__ooP^l3bJ+0Zwa4X$$8v)>F$8UFa=2d3Td?wV&_d45x) z+ebXJ-y>&aUpb*hS(hW<-unDKBNiO-QHR{_Z#B%Blz!fx<;T7|yx*o1=bqfH#znUe zY`O3L6L+0@(ag`UZ~WKCu6**$n)Pe^C8KBdZ)c|txn@S|5BoKqm~v9#4{KJ}{r08< zCQr)m^+uC77Tyydf(236fvr$M(L-+$}+)lKV8 znB1u4+@-fq8-HYGy}x`u{E&@Z>c72U=Y?06%mJC~j}`Gj#@Hx3whezoJ%h95TMpd}A1o7XKnYsTZ3w(ok>QAcTZ*Xl9jqpkA} zf2--7V-8te>xkp4H5l9PvJ*O9bI-lcF4}KF%C%Vy-d;H7#%YD;y_|JT&pCC@9C+A4 zX-9l=`k1o9IVbM-z{jO$w(a@-@ijiV;iVd9k2&}4^=FJ*Q@a0i^Dg9P$?w;|&uuYTGx~9}R>HE?jYuA3Sf5WEhE}C9@-ty96f2v!h z?m0`E)LNW<L1=4`*fonFIF8?GAX0p*7=!By8ig;%Uj-hW6bK?-g~a+u93$|FW}0*H*WrfCEHh3E&AQ$eYK$bfQ=*G`0Us>hgPXK@5C`3C-!_|NYib-Ubw7TgLQ|# zQSHa}Kc0F{x4)E(nqF?fSg@- zmUXPL@r0YEy#G@BrNhpv^6{3{$FIBm?kdOM_H3t~!&>#5({J{$O`|XDxqISkZ5MWY zA+KMz2j8F4b zy;}X34_&^p{TEMOF!${77Y`V9>^ZC7{Oaa|p1b#g>eK72UGSTOMs>UD`u)EA`_i2+ zT=3}Qnai#^V(cXYKDhp?A${xZ`tXje_s$sgdi5%WGhd#%e(l1E59fb%PuA$`5Bm7< ztZplAy5N+xuaDccLY+!v!Pf3|s}k zK)wTugSX1lG4(Fo?0Cd>wgt%JEbi^^iKY%jWiE!=KkKA&1~#tsw0az744?q;dt35n zj6^OdHCWE>$U7zB5p;t9*s8KT8GKE3(zdTf^(0Bx4t&6*2d%N z_@uFdnz*pBWEdMl5%)9#1??VC)8XNxHXtqK4s#db1*`-skQ3aOdat>pqq{lhHfL#R zC~ac02u3Tu5`^N4fHeXQhDr<^1`6|w;H%3JJ1R7mN57#z%q0R6ow>(xNm1}L!NmL- zs_F_X7IN!nfvWk9L@Sv4(UarEcAGr;)_7Zdo3&f3_74t(7Poo>r+VJr-o7kKzK0H2 zcCreyTX?R}41$^30EBfAM0|26ApG6w;mTM<6ZXa7`)JHFz)(Ox#@VwYtMzpXC=KM| zU-=1veSr`Y65X$NH?-C!D=&#~4wO?Zh*a-jQCByfx}m*BD4?a7~wrQ zQ|N+F$K%IG8hZ?#xssvpQeh?58-@C-U;CP#gS(u)s?@sin#LV5E|xjcRB?osUO%H7 zn$jB1=Th_xu$lm-L8(eI-%>uEMoUUpv)TIeF`j`U=bkJ}Da07)_a1QG@vaCDFvJT5 zHpNmLjYd^<^QjvO&5+HK(i4AaZ8-dIKKjv*{-evB+j7mfo&rSy_}0_-!Z*D5;)^@$ zo7;bRGM)VRY&IXb3zi!>Xb4T1J}$WYi3+{kl9s+?wNJ_6Hy|4PmG8|3ZQwn$a|8N#7IKS zBXD7U-3)>YiQ995EH&lMTk)Zolddd^s>;ky74jBdJY{Zq;sYufOQKejU?rQIoD{On zOiRXX>v2xyyOgVsJDqepW4-{#OxfXi(BA705Z86gfKQJ-vW-BN+t#R;H9~Urlu5C4S=kZ+n&di3e^yD(PvdiKM8OYJ-}!Y4-&&(-CTYN?r~bL zi4}0N*!hmm!O^3mT(HQM9_G(;VvfBDSp$K}a!oWxu`z(H?O*=u;T_5=Cw$C!1(LOIV{I&L&C$V~325r+xLldnbviS!W~?jF zTFm5=>E-$e^flcpUel_n(I{Mr5Qe$f*|zIub9oN^9;dH|TToujLoyaNWHz7w%JJiq zzxc6_edOhf_FDPzvn$aQdPsBeAlg~4qz~^-?@`3xMiN%aXC5z z*5VO!9*w0W02HHI8AV89+7od_;>8G1A$~z!CuM@lY<%Z=`(5{cwiY{Sc6`q>Ha6B} zwE}=U#^N1NSKbQM;QBVE&=i7cm^4-CaBrrxrw*lJEZmvzc|g5BLif9BVfLn!U@=p` z<=(vqx$J8!1q@@w$^lVrWri)!8y7hikAv~L?~Y?XxON~#=)uDW8RhS;@ul&F>;ug; z2}}e&fGY&ey}S1;T`yU@wnxnJV~hvauE`qx`q#deuk%{W?owudxFV(kN-*#~1;qt+ zS8%6)FNIw?*LBy>*U`3Z!+N+5fqd&-x8#*k5h=n)gIboaQb#9K%^#WoK+OAScSrMQ z|N3_^yyNdNceIrPW{o{9lwm+mv-YhRj*rvvDwZW;N+5B0===KX>eJA&c(=DV1-fHT zD`W#`lMq`H(uC-E#=GA2E{)^<{rkP(KHpDV%l_Ni-l~osA4$lvXQ2l|)s=_cT`%VI zM71=aWjFOQ1!MZ-{23$CbT??%)Myj{Eivmg991`N-qbmGboeM2%fch(U`!8JktO4M zg*qXGiO39@OecTiXg2-JpZWO5zj7g?EEjxBC{PrDZwXm1e(3G@zVxqDi^WfkN2BkR z3!T!Pl~o(l$5$o>oD;}L8uoBxR_26;BUm)Uobt!_Tbo-ts33@Oxm!w7vSd@-lUKMq zz3rkjhOF56$w?lQe@ASYYHQph4~25Z$ND=A9L71Ks#v z>L#48nUXrHoViV~FxZBM8V1D>k{&~*B-r&w#w zH;M=I1SjLsl{S-KM(t+hAZ=vTXlSM8wiN(Z;Osim6q}OB%p<1|hIClxY20ILXrR)w zv$N^m&hCHpp7%We-~H^*{_Ht!vTw%jEYIjtpeO*}<~{NJ^UuFf4MsmR9FBgF18N6* zbxGOo0cV3`NND)iYOb2m$TEdi<879ot7Io`c{FJ0?mb_G}H3wE_wwX4|Kp0IVcJv~tG0EEBc2+YOM^&Z&RQ})Gpq;tW)v7E^WuwqmQ&ox!5 z#5y)b)m-gPH~#B&|Gj5~|K?!OQ|Fb;2waBgnu4$8a;c?6@Vr+vw45(90+)7WyM>2H1D&3A4o5X z3ZnP~#iOi};nxly9{$I#yz=p;IrKMcQe>R)V|LQ<)S1+bx z1HfI#2aCN0vNI{N#Qf0Ev1T~%)|dq0m9f827hgPnDBGYkMZ?6?-O$TIUy zO=O|~yU_ifuyWU~?W@|JTTnXpbtNF*-Yzu>4poRKG$mQ8f(MfT?TjUZn{<_R^o=eZ z?qylY)XUHA2@@_}rvPgYH!CjA2m2@FPGd4YC<;X=Eh+=dix8gG6)>-Zdm(!F*f}*06qX}1Ab8zc z0^q#t6tAlhvLHOnCrnAFhJc4BR-_i>J?Hb`tjl9^Hu;6cV)pNsfw3FeI zlA=^SYdCXqV!7XpNg)C9#n(5)+!a1h;}omR$FsX32(PpTV~$nHx)#KFrcTnAx;jtrIke?c#-nyXT-cX=8lfv^@W*S8FI_s0c`G3yYQV3 zZB;xxwFnM43a^=3+mj)Nma&*ReFQv^_T3i2r6}V(uxm$`5pc(IUS#zHWs!ZzS65MVCUy6@(DPpM>GP#%13F0!Wkyd3en z`1dwIt3jvZ5%K~wSoe6D%>8X2#)ndY#fyuW`NcgEg)Sipln&hK-5v??x=4fW{)$4z zl#Vs6bGN`yTiEyQVtY#zR$T$zrB#FEw|IXRXsNt%ESZc&ya~8-eN7=RDGVX?^SnLA zd+}~DKhC3{5focxl%xh)C>A@r*7ym9fi&G5X7ChEN=C1$nUD?`${}ZgihqPrQ2tvU zsZO^2@9ckbND3J!|WY&!jWdpq0z!G}KdA^V|xzUdSw3cxp=r1rq4pMLr~MjKmy zcXxZ|pTm+SH`_jzro_jP_c1nZ)^fDm+>&v&sOBjY;n>_z&T^kXT(ziv_&C5YVi==$ z7vr6(4X_3iwDQ2R>JosEkbz+qEafLM@xJ$QFF-`OW|LJHJIT4lNX-H)0k4rSVV^p& z@g6?%9;7ryw}M2ERm?I8bIljaYdlo-KTk`7@8N?aq&l0^mxm?|;S~i+!;smQIN0 zsPO>Az3SjeXzcB_`Ca>RHeb|rU26@d)5o*v{D)t8fKKBEv_d;k!0;PZ1VU4c^PD-v z-^6sO;k~z4Jvln+Slu9q&DtmK9zkV19yGQ>6fRy`(NcsNa39K%n4#*t52|tM^RDh?-h?KkCicbVeZB~Mis*P4gm+-Iky_QYZV_aB zPH5$tlrx_n;|1(Ute;qX_ueSXE!7Nw-u;}Lj=+KjeQ5S?6ph~~K^9{_&vots(K8&} zjT;5FyQNVQqXN*UYItYBGDvg#B=7pPhUYuJTAIhH;u@e-4HYTQTPZ3^X|583$ zg_e#iJmX4@A@6HdtMvB7*|I$cJF0!MW}zQj_jdZBT<8p_<@Rny=*k;=iua<>F#q+n zjq3hGOZDR%s2b&TsusLtg@A`qZ-J48W4y#~^j7d<3C@Q{Xlwzj31kiFoQ1*!ATJHW=^{UX(p^U=ii8wH@{jq>PUUmt;>xHRpvZH#y_ zEbGM-ALhKau~y9{^z&%IxR&Kn^W9&1<<~#AjDC&$ND z<~CL;ON8mOrkB4vKbs(0q}}mFAr`HgmFp6$ZXbu!MZ!gu;3)e$6QbW_&BaI4W3kb- zd)~c~20m_3@hEgLZ(NFjG6d!Vk$P9a^Nvc>n5N0(;u#1(@D#iQ!5HVI6<92U%tsiI zTkpzYd{^K8^$v|$=?a;%Q4v!~=Fza4oHhl0;_oeCwu0vF(Lu?eV3 zWEdNtd&EW9GukuswLuV*=6m$`kvvLX(%8n_?g7v!I-Y9VAn>^!L5r~Wvx*1Hl{1eT za2;MGsuH3!;Qh(r#ST88J>r!OL6Wey^h9XEGuh|73t%gM*z=BE>C$vQpDzZ(p}Ya> z>ubO82Y>(f|LG5W-~(0;v3%Y%1uo_?Z~7Hi;{iYTgFiU>?Js}zZ;gkeKcz~3mVlk< z+p_Z0frO#pu2d=2aKK=?j^3Y6&Ej?@l&xs<0gdmTd`(*KiEQCC~ zRA!ek5DvhC%DvNVg!xjK805@?G2P4fvWs;^aRFzRQos_ZtP5Oo1`Shcpr7lJbcvh!~qPp&Wc`ZqoIVK zud~TyF`G?mN@Aa$p8n&b)8jw!>aYL$XD&J;<@bvzP!xcRX>{QgFTC)=yBGEFSL(Wc z(aN5tKAKUu8h*~)h{-Y{1rI?+`cJef>HFS2EC>j+XxElo!Y0Hh8xT<=xSMAKa5IEE zjGFhxHluDu@NN!m1dwU;WCj3wLhwkKpNJ8|c)aZ`F;r`Qva2|`iV~^3ty@9X5)~!| zBa2@A7NM8TJ|U%HLJpql0;asN3qlR%ffeJ-64yPzB7j=weIzt!8~oHwvsx8vw(vpA zdQIUJtTf9Ouyn!8Snl$s(7yTLK=;LNZ;swK+PF2hd`$U`{ea*ll!TD7!cCU)8GAGM zd{u^ZI-SzdgEMJi{M7!cZKP)ryw$vDyOVYreI4fdqN;Xb^fFb za;Gjq3MH_upds@{iSAAS1(Chr9uIfPFN6Dh=IOsH2;m+;_yT*K=et7e^yzLI)`~N7 z?b>zWXjEo7)q0R(znJ}rS6+GL7cXSa<$`l5P!xbSxj~+N_J!vMqs2cOkH@#n<-b_W zXY))~bK4J71+r7Qy)C9_@h=3bGbU$RR&JISrW}?g^+(oODks_%Mri&BImMY1@*765 zyR#!J8-}AYwC>nH!##r7PVZ5AJ5yyky~+iO#*NpId02f1<2%i#;REKu@(P4j9L>H<-pZ-< zR2Yqhi=CaFnre=ZjvoDcpZeq{|JIvi;aB?hq5!;!<-dLV_V+L9;jayc!%e_Fa+wmV zwsb#zIOs4r+*b%^wE&GVb3>Z7uE)(z7cmKxthF0si-5WK4q23YdrGVEHlt=`?yHy$ z`Qv~OIe;qClMa|MS(v#O?|tGq=-vRt8DEzv zMvsRWT8DLaRRjk^882#5mi%6ILR?>0=mcwl#wzNI?(2ZUbII7c)-9!iU1ZUtP~ge& z?tSr?aw$CF>!p3rDIDc{OY`rhUp8P7fU+#jt6qko8ynayU6w=Mn6D&9I&_nepHob*-wxb0p) z^yL5ZSdjY8{LKR80F4#vjCs$?jH_Wn?g}LxE2ai^1&SX&n1nhL<{JOYE^3q)3m;6g z_2CTB-^X*V9UN$!v0`9k92W^*zg8(kgF!0)+R%#o2}|%UxjMUU{BxKzPhk!SNGnWY z)~J74e79$5qsyByz@+85aYZLtwtX~@kK;M4DX482b?>^Kx3%QpF@+>Dcx8B}lONs8 z{++vMd9->mjD{g}($FC)3D8L%r`_FL2~cT(sW^`R9t%o!hb}J;IpoPzOfL!6*53nI zkdN`rE4+I&4r|J_E^!~b%LfhZ(djI-90E8*Jb!i z&qeJVd^`WF;ZwJ6RVOFMYBCg4c&!VkDX#T!C?FpOM?c86Dc#F+*OqY{advw8U;oB$eB!^j z45`aCZ3<5abQ&tDk=kR3*C-U?)y|wPJ{aAH zO&@xG5Ca8-w{A7w5omI^le2B9hHA<|+%(3k^LtY+yD9X>XnCf)7rUiY1p>z1lACWR5VJYE^{(%FaYkEtVgZVaXFl^bh?b5QbF8!T;T$+~qv zF%8gZ&RFHbryOt!#mK5wWkE}IYn_Dv#&nl|3_*GPnKA6_?rQHMV52gry&qi9K2EOj zjGD94iCp}`=tTEYXHrV#)A>5dp`BILJMC6O&hrPHj>>93JU_D~zj9@m!G37d2^T|E z(NBN|M;5y`HNk5PaQ4+SYBFq6>hr{7wJRaA=a2y`~ z%qKtj%AYH;>WxvLC;)GaUeyaPyznPS9qwEh-qGVHEjKj4D+CKgVX&Ubt%u1V^wl;vlsj#Y)tC!H zN5xA5@UH8I&I3+BGFfTKIQ615%#$J~FfeE=42AcmU$O4c5eCZK7$;V&hg~A00tCsz zw*+gl7RhJUdqI`^_UJPERQB0~GrnXiN&sh zK}lg{Ufv2<`TD7cMxTO$!ne6?;Te<$_7e)DGUiu!fc$&@%YOBHSDj7H3Uo$bqST@E z^m(-|7W#1kdtCy?TT;`fSX~cVaSl-mXa~&x#qwwCsjYHP!qCR%rq-IuhtWV`T~WZ8 zH}hg&oK2=HfPxWhSCUlnE8+r z>dK#oftrd>FMeJ^y()+@gr}LrC;gGivsWlmj6BPdCwoMs^LH6 zL`|o-{TC})v0FVBS8KaXBV*Kc19GKJh_jK^tR}UgwPHH}a%#o2U0Az7gpUEL;={Yl z`X){~)AhOKY(V@oez(|M6K8}u4J^5BW8AcPU*q<-v@+{m5I-7&n6q^*1ed@@KoDRW~j zJXe~zIV~gIx?T)>w+a1B(^T%5YvnReN=dkyDd&sz0y=InsTzUu_sL{SRG zS3I68%Rkif@80iwi2r(esw)M#NKd*cXAJ~F@0IG~Jv=n68U}&eIOoZk9J4c!g@?UOmQ^}*raTy`nL~%b zfFWVY=d`(=wxTE!W4RAu`c}-PYq8n5nr>u{anSc2I#_|ekutM%(LUcfDst^yJJ z#JVt*8_eJgLbN*0pB=xN_qy zkhw2<58TpnVd8OlhDM4W;R!rOPG_GV|8^R&Pk4`&k#5#w6)jd--G03bpNJMWH?M^c z@c!hw0X=IBCPd9@=<6Ckh6ZDowXvIx8u#dNCm^EKjG2zI=S7$$>Zt*U@_h76P8 z$|#B-e-5r)OI`tM{cNB#688vuDQ*^C!WG!VEfU+8G<&SxBjIU^( zi|y^Lnlj>d?%nzEPrmZXPo2jf%5O%2q5xcCCp`DubGPey_-b9(>$ADlsbXSYis_UK(U$pF3O95N#tUdH! z*xd|KXycaSK*O}X8d^6sE;44hP*b|#;e$gf?$dE2d&yXy*RDCxfSv$;b=}N9mqvj0 z6m5j?}={VhK_B#0>VLQmQ|%Njo(ArSK_U>ZawGU=@6Ac z+QGuesq=u>hO|s~_gY@}W6{AC0RC--fanlt@9SEq5;O?VkU`K59+>iouebr4N_Zqp z2Mks3KT3^mT3Hy4a|=a4$}vT&)~vUHO3z)O_&;Jd$PvMydR;ABFdR?W_@kDu##Xw~c#C_ob zvoKaMll~vH_vqF0J^YWCAOZ%bJVN^KpN?Zfh)NS-P0ryKvaxVh$KxYD_`lU{M%X`x0$oi z)Ef9;iSzG>G|57xE|5DIM_j>pT;9W?;O4^HgY*b)p*?Q#~S59zsvlfg6H!>fueupLI z7y|aMJRPZMkCE1DUGOe`gO*+akG(=42LBEkbU~_d(XHewfV2}08E=5+kUJd(NKFcg zyOjGB3ST!Dj{MiMIxN);91P!~M6(vlKtfZOyv4ECW5^2JpjK5(=&pi55igO(w_1!^ zsLmT0x%bHNL(EY$=zOq@GqxqwN>~QeTCWfiU%TEls~zEOo;%=V5iI}DilO(54BzJw zFzH*Q;d6j%ZSR|x(dg?ySrkL|Abdl82r#hljHMRo7G|=V9O7xIE(+JFsTk`zn=Ljr zHZ;hiho#4b9DRm^PdSQqvkOx5cRRyFki$^C%U?`uiXIAggEI!+v9+}= zYeTgGl2mm-Pq%51iGgOkT<>#1sHzfR6yh>Hl3OsOS#?2+t|;{}c(>@8znCE9fxYH> z;~LDuk9n)-{B+tBVd;R+uHU#J#)Gw|@u!dn^xNCpQF@~bkZj>4>zfoZzI*uafkFkm z3rk&Y_T+&vrnW6cM%6?tW3EGKKv=qq7kXYhxF!XR-(;ivTuav^}vUWx%WO#eMGU??tW&kM-Sg ze~EbylVoc%db6_UEfkp}T^ogZCEvcF``ae-{Sh;P~LTHo}f=sAh+lxmv7bK)7H#4{m3_#EHM zJ9htWHJwpGJ}Z#etG1k{BATV!wrJh5j}}i>8j>|;6&X`wn%qj( zT)&PC$D-ro%&}pNhLF#xgb7?2#~Q2t86%e1H)g0pM5gd~dLrwqHnOyW4PG99rX$vc zULTPjMO8Fw0n2yZz8Sjm-4d3fLr7n12dl9ceBUXKyiVhvQL<>;>7=~R-RT#-$6sqM zYOLI$YR^McFVl=w@174mH^ro>cR;v-dvp#G{=wti@_3~5wN@%w$Rz07Kg=r%(FXf4 zuo!>(^wue8wB%}Yb4$YK@bF4hs|~gptO-OZIBRQ3XdOvtnc`=^Sv`LP zFKVqx;S3M*_`Q2SP|Vn0^U?~D001BWNklXl;-I5t6zb6#>J}jMVVRl|i-d22e};?gUmAE(Z`U?T-_U zGJO&t6pw(@!^(_Ti@{(}PbQPEe(kGY{@y!x?$BtWd^!{;3c!jjar^e|zc3n&|NG?b zl^x|EAZ_9vk81qNt(-3C2_Y;u`g~qdu2VoL^`?Jf=;oxsOL_2K>cnwl^Am3on!tc< zFw-(1&jl^QkH1Gt$Atn6LkB3us(swit?AEk%iG<&SoZ?_t*zy5c2W7kHJ%&)Bp2fv zf(E8FpH(C(IZm&=9Bm?7xltHx({awPcO@x$L-HK3Ad+=WqZ2$DP5ptk0+5xb3%xVT z8%OT;pwt*Mx9X5uzm$H~_VO%)E%TFu^$z2w5M&K1(pqS9b6%+DRy3h8F9R-K6&6bFZEr&y*UsDgW+iB3N;hXi zC#f2hOvMK_Cv`}s|C57`XeFp`uw;aav8rlSnDJQ50hm@_Y?g!VC3 zSN7P8%-yYc+l!X2$9R0-;;leQWG&jCrk{a1(hT6*tjnNPMmX+i%VIGv2>0E*c|)N@ zK$OkdxkxMopJ!5ZWvp~QoO={6<^#`(T@-aTQt>E4FwWn+UI5p^A2Op&xCH}3TWM!I zM{%sCV8kzdiTK7Pv8C{Yiq*~nMU4p_3MrArzhAU zJpbUhmWx}9!*=)XT?0LNOU%inRdvh{3Ek43JU+_(&s*s*xQee|v$*wx2bOt|;FsNJiwGjYl_FMD*D1ZVlM(hB7Emfb4*~6qq|Yct^oR+TG z`RJm1n04g(E&{$gldem7)l6}Ni{?EFPgFMg!Xp)H*>U$A zII|zGSsw~OT?sUa=ZJa(N;gb7^#EKENYM-9%7hf;4ww`{!uYy%8zP-srJcJXARJqN z&qegXdqVi;=8YTG{reAu$pAo_KmLyNY@Q3aQ6RXwQheVl270(mIEi&=MD2~`&V=@; z9{0PzgYn#0Nn*q7&_BeyMwYhQfIaz*W;-Z>cwHJSQBq{NI5dIkDj|#!#h-nD_YUs) zY4&dUwNV(yv*bz(qmE6WsAGVlhk}LT6OAnmXJ{cHc$7D(Vx9Q{gA)la@s(gT-N$)W~j1sUn}^wUp&$7nSE!eB7mp3g1U-!AZGBg+JHcP~t# zPcTWqqyve2Pr$2WiN_9GY}zh!!@}6w+>o0Lw^;r?^8u^o=FiJj@Eq})3a8pYFBtdDhZ1Ur0DP7F&Q_-KzO<5KhFzK2{8V^<>E=vT0 z#+-Z5TWoigD&_Qb>%m>}yvK9&-BOkE;Z2v43qD#N?(X&PEGN2gN4ho4yrDaci8Wv? zToHg7!3beKxhPF)#_>8_Kf4vbsdD(L0!_p`QS?S_FzaB35B;(6l=VF=A_eAJjo+(?xfhl;T?j_3hk zjyhjp6jB|CD9}wWuJPO5FOw!XetTU)Euhvnq#-}wAzU;TTRIVV@;nu~lr zSBVZf8n>Uj{gKgV^aD$7H@TL2Ob09^xw3`=+{Dg)k$A=ctXRk4qsKZ3(QPRvXL`oc zCt5N=?&s^AIS_JIlN3wHpLvLQ-oWSZ|hn+i~`p? zX)5+^mj{9_Ji9nE*j2OklZ7*+FxBWUIP zrz5HT2<>jUeK zxzc-(lp_f!;X=GNMWyqwxxOjS*n`6dQi{YAB=Eq9B;%s%tx$0&>l#R$-QUeP@HXu3 z?Ii_$+DdK3u(17mU}=_Qu6UZ|ys9>~HWlu8^zh*l;1i(^Cgs+fN>3A~&1 z#OMrl%md02?{knYMSG5qQw@QGeeFLC27tU61{zD)z!^@sz(AQ-J9|47DXMqx-D|R6 za!R0eIduRpUfj`eRFlGedh+CZzWBv2{&wQD{qdr!BK1lgfc8p1_snxYK3*ID6MT(JR{*iiVw-Ghyb+D05*)((=9 zJ92kdOzYlV%eQuGv(JOUvthz+Sw}(4p+mqqL zRSTZ?c^`EcqvH{Bs3U<1obkY@u)46?eg55ba5}m2z<)u+d-q2F-76nPI>fj+h;>g( zJdcM&u;2=v??M(N!I=bvXqimY^MsGP&!g9XtO5Zv>9-#Cv1&Bz?kWSpL265bN|qdo z8?{Bq-GfQ|jbY-vb_r5|w8)R~vg=SWDrEGwvWp*mkM7%k;)}g1@&+ybaw=z8i z6$_CMW0X=%RmxQH(&y!tEy}!Sz_deHjTKJ@jtXsU)9TjJJ6pif4zXHiH);B;TdOBW z@?5ym?sM_DcbEPERuSYV<~*x=c9mwB3Mrw~JB<+dyqDjMH<>p;<+qapqY}cyL2nmI z8;7MAo7W9&ilukPkfWX7=EIs;1_#ZtIFHr}-Oq!(i3xNpVCJH-w$OQj>@T8+z_V8I zlurf$CeM?ff9=173owty$%jAl=}-S)dSut4yYSq-(=I3;0AnSKXP$ZHJ>%i{vv{25 z?95I^>_w_#H`XO9bE9ddkr{mlC}sd8Tw{P*x<{tH-Ike%AI0Tu*#-ut>jHzvdRCot zw^}TSxXk0zz@r7|0@Tpyqf|95S(Hhjb4{Nz;V7NxNHH`SESwQVcIp*^F=pf-06;cH!Y*G8i603Py;hAX~XSIT@mRj z02*uB6DW;dL6bFYS{z$*A@&qr2*)0H3So=pzR*2~);)ispNct+wAXweTi*)m032&f zvAI<9I8t@jMenHeB-xw=rH5pA9!05et+S@g!S7WnoL88(h_QUftlR-{xUBFKs04ZspZ zC(L&;Ir($1edaSi^JdTDJK_GTt^nk1{rvOKe{6ki{STa+o-XF|dHdKV;nKM5y*_*g z!JY166Jm8*Y|8*?SPX1_V&j10Bs^8T#;RPcD@IxO0`NF6&V(|-jBc%@bd0mjR87J5lgW{bI&f1r|-ba;!m#UEC4P@ zZ{+U#Q+V$%hu})(p4?S2~*^d5=Ct^GTKxpZL&|o+oSI2}&`ZN;PUfo(s zsB*?dScE-7=%cG>@oX
(v~sM6x0N0c3KoWiHrfrH7)22%a{HrHhbcUVPTa>nfBL zfpR(*vJ3|m)gyV2LSAZJjg{E9L+v|R;_DtdvsgAMYb$i(3zutI7j53dxELQE1H3C; zuM7JU<;Y^&){gn$U|*oPh@$s#yVqhBXvPHWg+tZ_C_aqcOZOVz>2&u?AVSUx-lOB= zlLnmJyM}}}tmv7xR}#>1-u`#4%iRFkGxRyhSs?SQ1hOws_@E6;G-bRWJ9l zwZD`bDgk56jcf`4n_79UDNV`1JofR_BF}dR@vfM%EJZ3>E!5>qG1H_Ba3AkVtTHo9 zT$04S_@jE`Rccq7QkR?&IuaJ92l$6~^u>qW^+>fj4zLLqvFGL(b-0+obgC+7Ion># zK){>OwePZ9fWwQm5kYr;uM0cI1Ev86?zzy-Z%LR0?&j8=F$8NynjBKY;8lrZWU^~Pu{A*irk>f>5P$njn(;L7!BYlr;+(*%oG<44Qc9l6AWvxq1UNa?>9;H0 zgMfE|Fa_BNfc*IIcTc^9-WKMu%moV{&NOyvQ+U@?)`$H}1yYs4ri&^yOH$av0f)?0 znF0AcfOTnjGyHTqnf+g%`OK?-Brw}cs!5mSaBq+6uCf9ko$#fXUV3qIdiE(+AJ;!+ zB;`WX!I=rj=E4JJXjYZ#PHUs$UM7}E997zOmST1QWVxq$L9)6N6X3W+ly^R$t*Us2 z5fGZ0Hky-(+<07j@7~?q64T4HVm(mdOfJEW8#XR9!j0lQ#5rGo{dGHRF7VF*gaPd1 z&f>jCM*l;A875>T~G}tEx``7e*TLZsgnHrg_$%1O7ZLJ`l3IQoD+GJ%crFC{v z;+BP0c5($!TR+8JFoS+@_j~*lV0+=Ccbx;P^vjE&vrw*5` zwXS3ZV^9Dl?J89gf+^!j0#+W9i);K_xMiVUBY+FyWwz*V)3J|z4n>Ec9p#VZ@@08 zbC_@`W@{{~jssc6G2hmGhhD`jdsx!}&SIi3W*dt8cySF2J(wlq7zSV9Lj z(F8@DwURQFtWi$|^!U7cWV%9cmG{9d&pc-6H%`4fP$+xG*Jhrp)d2PwW44|F?YC$CNP8*|1YIKrb>nFg?amn`!*l-G`-EWti=qhNje7hT1)%9r*~q6yYDeOtKqGo7!v0bVUI#+z&gve z0B1rt#Ean=|FWJ8-7^&p3Ft#?A7uhXRse=TdB1fh`aWKnFVy>wmqo<@zTxj|RZ|Us zRPN1Z%FO#D->qCWoGG7sZMgG=zs~=BVdb(_y7|yNojDX>{U*-NrhomjpMCW|OkbQ33EpuAh76xi5^yqi4N{2n|tuUp0r6wx77}$hV$I@OsgS^UyWUQ1%ZF zQl~eXwcrLj$*y9Ulb9C$&mSH(m9Lf{O9tej32|#&0Rxc7(z5)`YJ!0?=2K#hiSebv zw7L2hV?nrpF;HeeXdw$D?xK6!yWY~HM#FLSLaIL?_W zu}&8tD%{lR&KK9t+~U;sUrwEhbB1xl#Mqk_0s=rbZMoh12CVQ54T3u0$*vM&a-}IP z+hXDiL%kyuV*A!!d63HD-ne<)nlhc7=)4%J+`wH}tYA)H!q;zHmyq!SG!`23CtE*w zEN68=oLC-Z#uj<;05 zzV4$ahyV-$Jhm!4h6P>?pJ=i+qOp{L(mmRMWO~Rzk@qlt2hEjvLC=8PWz>FG=Ed0B zkW0A_V!F@rUsetpgU8;7=g7)9L!a?@_|nTSzx?T|=mofH3P3);&prLzkB!zw|AV8X z-Qux|X0?^IOf|y!jjhNgbb=IrEEPDi>0|^;}T$#yZBTq(pqK8v_VWMl) zdYqd(-J1Ymo%@_n=!}+);Pk(FgBX;MWluq}D3{7EM|N)yWzTW{c{%QBljz)_%nCb` z;?9;)>!8>{dXBewo~*36r%+eI;pqG&!N;B;Jx)S;tT=8fEa%tRp0^@NzD7zz1V~1U z>+~mr@EE6q9tEOI!YL}wzKfaSHb7snRo);dHw{W2g^Ps0^Dg(Gm{Y;P*LWoXI9e6O+28;4r(XRR(>Sk` z7vL%?0F?VZ7!60iJs8wGiv1@fzm$Q^b#0ELqbJ%NG~^)u$#vwR=gwKlwd@Sr)8w3K z$bATdIyr9K^b&$Fmd2%*Eg7!_*mK~NZ=2GT6t52MON#JmDDq6uU}x4zVaqyCOAvS^ zK=r*(UINR(HfD=k5)hrw=idE$8ExBVX=Ke@*#ZbH%M@Vjs*sq3uQSj-zT)xVRj%!< z+@x-ZSkMwQN!WTRV3ZZh0p~3d|JyP%J3}}xnz=mMmL=oh!WiC3InAcHSqvCKVkLt~ z(MwqXIrrJ#VX=Et7`(YmblY0$-FkKX+O_K8;iCjT>_K2hORJCAXK}M6{PJ7fs`h3}@UONa*xchv>Z&tmLbQ)5rJe$5jb%`bMhf2n^aS+h4Y1-f z=n=yFg{NBDJ1Hz#Tlyo{J>faY+oLgtn<6;0c}IU}z?B7_r6yWy>+TN!TqOk{OTydF-u}R7JpA*~s<8uLmo$RL4q3La6UffNd0v3T@1dcFQk9Zh z(kpTKGc0D0TXkJx33rz>DTD%_zUDb@RT2mDIH8ybH-s3z7;ZP`DKg=}RAoWemTu!? z5Yux;RoAI`hyng$+_fd=P&@#9O$?Ms99-do+K|3yrz!T`*=e#+hC^$9a*8EmtFs+9HLrwOJTSx9&t3wSs%fdRAmjJyhE@g7_J-ju zjFk{-;N!s1RS0Vf3HY8$VG*8}7@3dKL3`{C6ed|gYX*A4Dr$+Al#XeZk%gvw&pNYg zD=2oi6g$Q74G?LJ*lIcOA#M!NiZD%9IvV6 z`UQFzi8|=vAt8j7GMgfxw(%5%5qSZy0sSE?W!XQC%r=lA1F%ki%|~H>DTsC=)cYZ2 z8u+<}W+)o8FeXf3rOWMXoaYU2%U`roxY5p}R}oB})la?l+GoEnjp)jF0j`yt`Y}Oau0=Kv&)83Othvu~h~*7PThh~+9I(fKy&P)n zJam9+0=OML8#nWNo4nROyT#g#IG|W|>EVx=17$Qpw zz{a`+YE%H4Sg>VU5W1nxIKsVbK8{wNrDz^hM^Bz)FNyEMMp0a`j}lK{e9C~3*Ud5x@Eq`6&L)vkV6B?HE(57W{_VD-$fDa0Kec-avvSrh_mK30gUf>p)Cs^^(g?cPrxc=Amav8nj?$p zN}*a^zxAareBti|XU-dQ3$El!Z`~iRiUJU<|7Sli8jofDtJ7FLkQ>kYQH#m1 zS}!`nQTlXyTOceL?^4`s1x2HEBe&CVn9#e1)OCx7yjN?lYhCjEocMv`IxG&D10Dyg z19wrXWT;}|E^Hc)hB1^II@k~H-?NZ{4!RX0iZQ_cDKy_YL+8IR@Pqw*2@*US5tQXT zAX;!{iBxJMN@^UK04!O?Cqah=u&^9w+5|H{(%96TDJ{&o?#go-y%^Zdi#VAD4C4l* z2t$9Az+&&F=S3n5p9<>H19cZ*{L546D;K z1IPxzK}O!=*5%#oqx<*n$-}U_zb9pYbH*BK4w#iRB-f|K4BG0Z8Y><(SXp_p#qWBUpDgI4Jfd_eZ$G7#c9+lgTJvqT%(JH3{Oq*yeh7N@(Gw|c zzFzrp*<)t>!S_@4fCi6R8w6-SuqW|$=7L};r5b+8o=W(oSHJkh&%dun&wcAk@HaWK zi}&7}e8F4fc6?v+Teoh#Yj1b=w+D4ShG98k8z!ZETAIeF9=6pG@B6@Lh&|^xbBHr` z0YyueKzgPzd(*UCV7Mz2fdqr(02{!`tWw;LxM#`hf$ z$E;sVQRL8h?`&s!nQ`@%5moV=QEI+nUAu7Qdk^zQV@cSARFTiB{?YE!Qmf1(0$6M& zv^Q2j;}$fev&H79FlT@|^QHha^c1um)#axHx)>oTf6?3xZLO*ZY z%vu0CG>k*6sF0Ma%Bqg3#cN_Ds*pw&fB;OEM&)xm_rQ5Ahi+TxY>qESHhjEdlu5`k zP6xZOE^7ROg?fCvq}*oBc*Q~OEnbr>S@x>aT+LgmNvc>;M|MV+H>7Q*m+9tevq5TS_He-?J|s-WrC+p z&VT_3Ax+`DDr;RWtdSZB8ED#p;NSutJ$m@BzxLW|zi?H&0GHWU=bg{D!*7|#J^S?2 zKe4v9_Fv{g5?D=X&j>-jV{yw>y}+Hxn!CIK2FZG-8AWnYo=23fCqQj7$D{0O=3s4a zZ|M)MH9f`!gTrzGxPs7>+bYHQ8n<(*Ha4s+2InFDL;+&ZjBjluXQ3oKD{f$Dm>k%-w=aLBN6+S+m9-}Q+X_L2QXGfkIPoK?sTx@*P6;~5uVSFh&iSr=3VWREOia>BUs^U_@9iAjo>=?v$y*QH-q zTz|Qzm`ffypuAAw(-olZTJ#BR)+v_)4k4$wxAJpBS>4qNKw}Phwu9C&-QXSt6frOw zoIwQ`bIFa|!dT6=84r~p7WV&Di4Y7waGT3XMmC;= zI!XgE0iZ@rYlg|7njf^F5K61#+zKF=(@ZUb&n802OE9G3Fw6<%%Trse^I8*jVEisP#*a&O4F$;06iEM_jt7GWu(lYzCF_A&dzx89Kn0sD2e-)y~MwH zhbt5Eh$LmoTQJ9YcFz~@_0r~^dY7+Ho}4}fGFNU);K#UMjn2)C%pgiL^8EUddBpO4 zjbUy#_7o7Zxm%GlWh5mKAi!9|CCn8H#1mvZ(hMETitxftrqI)G1AakSW$1M6x`o6Z z3A1D1Na}{b=gBmPJp9Sg;~)5)-}#-7vfa{A=_65hN#B4pIA`|!QoprmcZ;yLMJA^vMCHybTn7q&W#p*5`o z$}C=OZ?zJ?4FJlS@*%8W5&K)@%l?xk>Mq%EA5%W0e_qL>zFR!K0 zhY&Xfz!U{zPz@%4bt1gOr==IJO#kL z5j>Ck5k{b%5&C$k_;{L_>Q$tib;ZkF0ifQ3upJZ8%v``t^}gP{oYs^+5sd<1Wv=;- zIZ*`=Kqh1K)Us%$v8GsOJ3CtfNV)<5y$Q1^#FjiR7jaCUYsHc*T;yxlG%K>on|#kZ zUHGw1yu6mBJDj&OFo(&D9rf3Wo3 z4etDgf4kRBz`L9YVQ0X?f#&JTDEP!!pjWG!U}9R#i@mMRqp4JQ-ec!;7gzh2*9-GA=2*FO8FuR<8$DkuP2 z+vlEn`u`aWhJR>Dh^5-s93~itZ**r0Fcvn*Dn`Jwvp@;8&z-ro3EVUTaskiA29Spq z>M41+O1;N6n>EHa-2z}kgPz8kq29HMgUH%!nug$ZrfQlg0Mu|dH@|`x(aee_n7%h~ z9I;WaxsLVF_A*?=zMC9u_og@_g0+M7R#n z9^MS9@O7Cp^s!XLVRirhJZV?LZym^a`+fPt)fk#c|2M62yRa%I|M?bi+tB;fAey|WY+iZ}p_0;*nj_=QntJ0B(90PsQCG7@CB!uY)UgN<9`9$I3pdD`V*A;rrp>fOB&qtJp#|gX&4D zE8=G?&oNZ#vvXsfB`{EU;M}^`I&5uK4<0^D-3r?tymWf=-ga#O1nXIUrAno%k>7om?ggDuDJXue9H~}!U1LL|K0C?_aC@<{o2RSXk!m{ z7icv5VZ%94%|isE)YJq^$1IJ;8jTXv8B`WnStAc)1o=;Mk!*RU_Lm%R+}5GF51W_6 zjm5<~trDD7FEdxFm4_wviXg8ews ztU7y$Radh)S?XmiW-ANT-Ik$txe3vRjd>v$ujq({s{HDjf=6ayV?Bb?=oYA161FS0 zu!nn;6_L80Y2A}m1><6@0;$c+we9pJb73?#S2k1TIqho*xC@|x_8i};lLKE zErE8`wfq?~VIP$no=q)97eFeRCsPXYG>wkhLrhBYhPA781EI}OQ)NW@&&zFXJvsXF z=iKY2wAe&n=PT@a0|VPmgnl_&B&0@)2JnJ4&&k2ybvP;C%ae25eV|S)T9+spe#v&0y8?p#VJdJ>T=!Zr;54A7K@u**8{C#eh;Taox8fuO8-t z5WRl$W_9wUsW-2w1#p@-2vSN~Xw~crM|VBlN(0HR#w)eetcM}Oq;frhgv(g9AKW@! zl{I2pqK#qjag&?Qo^++Av8pY7b$@qP%<#_LyQ19&c!1QT3YVx-1A9Ctajdq_twb%% zly|^fp%*c9EGk_2F~|R4k3J2f>h3-3)8NnGzc6uxB+9oJiozB9MnBp!p5>CjVA{U| zjhaB=57jM&0|_ti4w;6>uuaAD5EArtKwu)oTo9Am$Qri*!C^0&qpb0WTNYpWvx5!P68L%L2MNzzL|-=$evId)|!Ek6ClFT6%!AVqnJy1mi; z`gXYfovZ-(qHW)P=9%9budm%;QzL-9YMaG>aS?mIebbsUA1ZAS>lO&3K=w_vSUvC_{vUGFre9_ecCY(IDw=RzP@%+q3D9A=QUP0Ey|4B}QM z!@*;w=~O51jyd`bL=p}KAJJvUfkH>DIYi<#pYEMY8qaY1ZYFMXBA>-niSicB) zHF-+bB1)VW9pXAXJFE@Y37;_>sS{%N(9zWJ~p(S!o=}hE(t70{Bg{l?0NP`x7Q%gJ-NBLk#u^(v>WZu$_z(@u zyev727n6yVF{$;zcmP1Nws`+|FCXWjj-7F(*T>9r*-t2poMTl@O=rw2a{3uuE(Dad zgtRY6i{;!*HD9R-tQ86c9Zr)skbNaQfKnnWGI{>+m%sewUwbEe0p6(!0P|uEXHS3k zcmK(ajrE_WN*c_BO>Y~yVMYyjuVEsde{XSFPa)g2UH~V@xoMBNlr4pYm?Y0%0*eJI z+orX$v&0Yq%D9WlH-BTfH&P|K?uB}tS1L-R*nX&KuC&6e0NDM!2O&&qT1X@ib1&o>ByPxRoi5?iYP}TTHLMEWb(?d)Fuu72ryZS36r2s+V3S4})uMN-UkJXen z#mB7Kw#K%k@VOvvameN$ON=vk-n6$9_GD*gR~|G=do-oml{ktk#r@)$NXrW8GMkQG zfk^Kz4f^*N-bgDBgqLH9Za)50n2Ir@aGJ0;uNU9ZbW7t%Wu$qR7k%P7N=qYXP5H7i z=y(;S0E8w^M*F6nxK#qQlx6{H&(2awahU*y>B+S+n)JZL5n5KgJ(>b;wpXndhGi{C z@k~`ZJySxSy|m{hlj;BX#V>yT$BF{*M!D4Q{q?q^s%rk+v(NtAcr^UC9i=LZazX{V zlWJ#oS3-xXeqstBn71~?TqdbLeJm?#?l$?+4psk*=$Hd_3yAxkPs z61spzl6mJpT!)RztxMDfCF_g@e_ZnDDKV~x#=;xAYOFp0-Pi#LEdv-Is**RNfdrR2h#`(5d3YW{b| z8W=o6iuf;kurbL>?88ooJ}zXl62DOM8HO#!z4UP(k$DmfAS9#rBpeeH=Id-Db zoL>qhh~*%WUOb}Hyh;SvM*b!nfSV)jeR@z?PhItIb_kz=2Xqck8K_t-`dVB3Xn zBZ(ObKCjM4M{O-5T9qjIIw8uD-U zqV+nj1USXzYR!xqx4U~poR(xn82)8lEm~3T8n^T^d&o5;%owv&xD`F;Gtev)LFyA! zImDn&mci|wF1Gp(hH__5qinE_oH6F;;iX=;d^|aWT7XFhF!l*c3rCoe?E4-1vo_JY z0r->shf=9hzzF*jtjFZx^Xhw3>L3i|fpdjz`MtJxnWKXMEv9|ZiL~E&w(teCxIM8R z8hvwfg$>1o{p4rfGl;bQq+603Z6`zNe2hbxJdJ1Fp>tWXw-kUV{8yv^`pn#u(Et%0 zJlhC=Q;tp9etSWJ)C zNFkY2@lwU8f=OrA`7$dZM8#P8t#eDyWL%s#7X;lkme5M*egil_d_wL`9@URe-dn^q6 zyT7|%JwAHWszBM@GPaZZ3t07SFY_u4Du90|eDD;~L}@`D(+;Dsh0l>Dryd(gF~bwb ze5`w;A@!SkOkiqVf&yUrLE?>Za4mt~Z5XuNKq&4-7tAeQkSuckgQCOr!XqqSltUB; z*ABF&@PgUul8Itv*K?YxKR0+IxNc89N~|`3dDN<(`s62H`SGLxaMwEu4ERn|0C3eX zui3NDKK;|<(fB`!)~#)1147~6LhJTI5V9;&D_M6fIb%X8cE*iRKx6b+6`Knt3X`@L znr@|8aTq@9$n?PsVCqrWpA+=C*xy@$YLo8f%mRbtGjOSUPhRBQ7n)mx_4ZS z-Rz1!~u&7A1n+9y2(XqcfDOJd)^%=xfCv{#qfuiBo2$qC$ddYYRF5dxFa+*c%Kg|Lr8 z#`p4hYbOPFjF-&ZuYj>OJv@i92d+GwIajc~v!Y5?Q!$Q;k|8%fN$1q`wnM0NxR{gW zpKnQc8;J2tiWUM?^(PVv-D_%y{+uOM*?;u^%S%JlYwLPNemxBsE&OoZ=DfFXPCMgWClIBPt6Cz2N_d!>VKV zoL{r&p&zBOy(pE&rtq4zA?A~MwY>6~&wTn1Cc!V3+Q)<5uHRi|&%NE|hN}jtJHGwg zv#$(?gBNrEcq(P#;*#L0<#t6FS~>_DAZfX75XjYPLR_0ijbAKZTp@zwnnE)LXy*!m zF$^zHnOf0$f9u5jvoOqQD$7Qc+>xnobvG=)JX`^ghrnb|^9gZ1G2#$&wQ5~M&0W>p zI!xW#mCh6&2DIb#x|iOO-`02oTv+)}R-LR7??bgFwbU&MKe^=2+lFys?MX(ZfoGur zaA^DD(LSE8FzqS@2nXn8nH=r+trEmeKJX>nLZOxQO-uQtNr9nA1%h2R#x|B5 zOfX9*OSWZGkYcmt3Z^8Ic#Mrw_NZq@o<%d$(>=X+-zE7y@B4h;d(Qvd?$%68Gt;?$ zkf-l|Ip;gy@~+SOyk!ouoQd}%bQ>&^{CH#rW^kirSkrnOE%Ru}$%#YQvY`%!YHCAW z`A&p4t9R}Fgdik^#he)fz}{#Pk#_y0X{AJJhVjy0t5pY6!O=Y9N!o?T2KPe&;DI(T zG%+Vi%aj(-&9Erma)fwej%~QKc`6geUPSrhu~YgN6(%&P>J5UpMymoKC6N^|W+gqKC|! zY+8dLPzlXO>))&6uv+sd!fISySrdqfR}h~-R18hN2YF3XjwnO@;brLEd+Pfl z&e_^(C0}gtT4`V<#`pT+2oJA;z^u#CGRD*8nx|hlEzn@PTl&39m$t8|@sJw-tYM{R zOV=76#M4M2<7ZlxmyAOfUYbni#4s?eG&B0C{}FJ+tS(8{)Ej0-C;&tFRwym(frFVC43JI{7Bn=RFK_4lUb zTQT?D@a_MO=m!Sy-FMw3`e7n6!bW)g(;zn~5?-CKEZdaV^~!+Y`ybcUHzg?JI~L8D zo@?8bhpxy-Xl#zcVXS9O`ngfVAwPQ<*)h0Lw>qK=0Lm(1Q0dcwV#BcD8K>M2b=_Rv zS$mXtUPm&G`@R#J8Fog_3@mg@eu|p(Ut^j5>dceS+}}~ zp#T6N07*naR9h2>G7OFwb&DpHzguKCdo3Ura0p>0Wgn9&MKAEZt5>i5=Z`)1*!P1! zPUo^t$BmBTtNQaBOOsdi@D~Qx41k9oc;HWNZEgNFd+)Xm+0b+0&4@OI7m2_ik47le zO(;-eJ8TH+>jXeWJKZP)>>i`7Qg@sR^t?fZ+yjLho=!6d;veZk)b~Ret_*7eLB%-v zt-Z$%7Nf?tmo0L?00G9;9KmoYPHyfzEFpCHE<#E9va49)!K&2^7s<;Inr4;@2E44T z&0JwK0PN{!S(T#}C?I}E&O3gaW6bYKp=b0%bf|=9MRPiiz#&-qE<&RoY71rooDyir zsQI@_*U}K<*@7EjTkkn{Dld1&5Ev;}lLv()?%J$)dE~FfLr=#?!kaYbS>Moi)hmGZ z&!xsawpS1To7luX)7@<^g98f(pr>rI@l!QqiV-NrxRvFBCQ#G7bGtUk^vz9qW7AL}ae9!RzT zKqdm5hzI8!kZOx4)CgG4TI{(r0I~{(eec}Ayy=zf#(71oL0kJC9l(j-Oc z0j0657SL%p2`iITMRqR2AdG$qjuaVys-QY~;zN9=l)+O-_5O-Y;HGC@8}B`?DN5xfz_!;u`skyN zzUy|x0o-l|fb;*mU-G60e{yAY?e|3v-pVZwl2F5$7Zu?I9%)jW9s(G+vXZ53p-dst zAtZE(QkZ=4KEmPnv11bI2q`0oMZXJjIS;)LN$3gaK@SB9N4cKmNr?Z+%lZ36=Vo*< zz+%)#Ux9@V3;I|Z85lBwQ!wLN2u|K#7f6l>f;*pu1nWj^gb33ko(QErvL$t<6SLg; z=o)7o*i4$4-DQ9vyyPgrIX-_K%++9^Lun!mST_!P2UN!MoIH6#^L+aG&hcJ_9mUc|g9<8Au9b#9>%*OQ+$oYA(gXGu zn?g)uazUGyy@rRv9FPYUJ?%_{g0Ew|AsnZky}h6wYf3)!s=&Mj?8)x!X@4_^=gwbH zR0yvSg@f`vdhD1CxM;BAXir?k`=Otg@zX8tmD2d9gs(R?f>zm$hpZThB#{^9-oo9O z**(Y3`0hA)Qrz(3g3stq8->1CN@*uP9eaq7G-n^CIuuoZF4x^m(ry@-tI@oP9v$r6 zsbO2s4|CwXItC>!?gY2d2IMzRO@Y;j^X^vIT=R8zzb*{yOBa*Ucx*V^kSacM+*HOw zc+-)>y4X4ucHtM!1ow$_#Aa|C?CuVylL^ueLCtvh%%P;g8G+Kr0Obtx8T0JlaV?Cx z*S+p;fj$AK$|+!cd%G&xRRr@iYeOn6QjHRwDxO9D#$th$Zp>QzJ3}yn&-g zj@a$)?!NHoqrdi+VHgyhR+d6>e@$STU^Z%z8TJ&=P3#JTfQmBT( zEnsU2VSGQlZIcLrl(i5tC32KAMVGv}m!lBk+g_^Pd2|GeMjX*Z2_uA3K9^n+N)^H# zppQ$waxR+wDY%I0q1gMT=lQgGQ&sw`7tiRjEj)+d_@2}==i}AfKEfN%BRZ{J+n>*$ z*;!r^5s%713ya-U=<_%CFz}K^We!@`z+{5tdKf+^8}T`0J~otT40h_tu|_Bdgdq7- z--~PWo?4wcx>5`Zr;Tda(`cxy%tGcvl~ zJq+U*v&o=>hI@Vw>N&HmH1{#VlOt8^K@^J(0KUtdi@I5Deo5&yKYn(fd)~oS?C!-} zI$G7|bZj{Ua4^B0X;giK_PceE#JA}}vmpWVM9?9QBF)YN?j)?;I9GZgmrc19dc z2o>|lZ@oXsW*l3_;&XeF+2^sFQDWe?Y zCNU>`#`!vb{@m9-`skydxZTnKx0?Z={Qv7;|N7s2{OFNSwYHVzDI?51a(G+rSJeTH zt}`fTQ3Rc-jMAW}-*$r)D<#!OXDBLe&~G~aGlfBxRawEg#j2H}!)1=z2LFFyc(a#P z?(5%F17&=`V@wBB~MSa-vZ(0K_#B+~)?Aj69j*XE-I>&P^z#ctC zy|06l_7PF&-oBm>d_#Ir5JZWFS&VLDT3zN|;KM}VTe<0+2^X`ZIc>&H0m}ubw0DUN zM#BgxtI>_30nr4B@;HJSaqMpjlHqnN7&e-hiR`EHTyL*&aI{#VzNN5qFn0d?T zl8MkYiQ1?$C|w@4KNC=`>W=^r=alP=(6@p$b~s-(|5?>_;^fI_2YilbRhneEe=Wn? zJYy7)ZcQ!-MJp~}`jemj^rydvAWz}|Zi{Mw+sOdH`^Nyd_M5)^%RjiewfRFYoKBQy z0ZuiKICl;c;7x0EGlI-uu^uLmI(9Y#2MKTr(SfQ++CiG}T0%gE0vHGR>oOtgfS3`7*8za;M0-c@+ zLeai(T7q#5O!F@nYksBVRG!$&w-^^;(-2^5q1X%Q&)m2zShy349VLLVq1-;<@rh#M z?_jux#6bge1)XJd3vtR5Z{03Y%IVLiB4@2xzbQ~w zDqdLOD9fWvQ)#A5uyRd-Ht#lQv-gBRW?=9Ub!0B15+f;o-J4|ru%{<#06w2P){qJa zb@`Lw+&h@qqo)BA^9ws`@O|tIcinZjzR#ZAPwCmvMg~WD?6%Sx!ns_&{I@>yna})@ zGyqWkH)c-T@4&x`-oKp;0F*x}_S)ON^h>{gZDZ{}y+FvWM(CEpJH$f+^c5^oSbKO{ z5u&rlt;3uPDoGs%YjyCvni+yKc;1@e(GWt1T2Qvk_1C}!9XcP zniAz=2tbg}y5S~Y%`{SdVLkm&)aQ>k61kreHb;UAcX`r>+g9|c9!q0X;UV=)d4 zV1TH-Iac%s2=g|^( z9Pl$x*7I!R!VxI$%S>bR+JL|SaLAr9x@Uw!`@6eR{`?N(7&*xEaDCk)afqGczcX|y z5`gT>HtMSSC~{&v_Bat{9<7`XB0K}wtm>7j6k-Lf{Q0+{n&85Sf@G=k_Rh{DpLz7r zui|*;HnSkU+2j4H{NC+i0Pq|b0FrTE`Xz7q7sKA}SBDaD*gL9Wukt?tlG;kiYVm+^~tQ0#bJ+*eFgMK~j1UE&k}UXiEdmc$t~ZZh5BD|z=R z==~mtkkBu}1a`w-R;E$TZa^X(JS(L$fCC9jKmYvc2!ADa!SujbV3T7-IZ`t}^*s4b z2&gp)%y!uKIR=`N1Xcz@7~vRSc0Q7ZPWn!3R~t-&%IzK{w|hmxkmu(+0`(>^eKMxs@q#;C zdOgp(MMU<=mrs?{>hg*rB{f8QJ?W%9JiC8#CaqaZNZMg%D~}qkX1(yl?PaR6ql9DI znQDV7QKgn&PQhK~-*Yzgym_BU5w8omS7+N>!|(|mI9)?gj5(%2)o+MF6AzZgKHf)l z-Z)333&5!0eS*MuDyYo^Et^jsA9l8PhfAd2@*csxkxheIDxjwn;2FWl?aF=-c#S9a7!HCSG%bLj>R z$u2ZN<@hwrD0Z%%07neNmKGZ4!kVL?IW|tlMnyh^2!Hm$Wn~OxG!OM`B*1X{sVATO zlFOGbKb|-Ma!ret#H&)x??0&9$p8Spy?yA=p?mMU_ugNHt+RLZ>k80Z=7I7(DvLOJ zXU_wU6k;kne7rHDrkn669Naae7s7~6e9=t9O?SpJtxvz{a%-RTs{F0MRYEubwuxM4 zJXr}ywtV_Zf?4|alp0uFm5~rYs3;}0a1wwCzVtObpFkA{1ubZ1*HZUA@7yi_wYOg@ z8?3wtjYpmGh4wvFN@U3t#txAU=g8ZfXI|Nc&fID9eGz6A$b-j*v8R+mcBtdJcp`W| zY70H1Fq^lO1|aj*ysiSqi;!$YHKLC|L?A{$r`?`y8ToB7cw z!5W6+055mI1zb>VK3?DHGZ9ionaeJ&6IvvJOJ((p`F_I+z?qT9iF>J2Uq*U(oAqao zxu>f(oR^Olr^e(YkzFwrUou8#u017`DM(^3kHqJb^QIXxjQ4E6{w%d=g_Ur zNqMS;vH2_jR9?9}Tu$(*&#C2!Dy%T}jvtRwPJt+!y3IysdjbiwHW*C--h&kAsD8^i zKCcv8a1GSob05HuI6jVKw6jn)ciZOGQpv^j9DoN@acnsA*V8g<_&AwGgta>k1`H1c^T5P&KXN;$-c>jyMBtydBSWy3^Ujc`mR}bBW zA%nmUP)qHI)_W9iZq_#lL1ve%OVKl6PFZ@+Tzky?OxwXu@f76y`JQ z&<_Rc+TZ30Cm_o6fr4gijIl*f63_-94bajh7}`>3Ar!^_mifcE1E4t0OdopR(@I>C zi3Fi>Z*^~+5i)HR6MR&e5$6m6fxxmE(9~dX^rPP=NNlwCl(vb8lAB(gJAXdtf!EeW zHng9iTgS0@k3pWXz+H**=KHLB0Ogj3rR<&u@eFfJ#Oo536!0rhLO1c+KS&=!FqC0mJ1cP|Ak2Ze0v*Oka_*=fxgDjtFF#{Ua*& z#`%^=3mn#bC?aA~q7=*_Ll5cMFP=Xyo?S-8e6f%DJ$nJ;VMGR$)=H#m!y&RmBFg5X z;B%wQ9kmwvr>_JRN?^N;rwT5FSc8zw7ytXue)h9}9=jtk0Izu*!0luJpz*Fg@W31X z=;r$74@AB(!c$%7l~CfOUfWh{Adw+R2_hB0*zlqbPX7Ic!rsU3E-qaHzBIE?>GL#Y=57<5#&DyaVi`n^?!$O*!+Xq6k@y5oAuezPVqj z=QNI>r9hogV!`@%x4?OnrUnDuxRiFZMx#F_&5>I9q|q|dJz5N2@5>njAog`H!F0N3 zWGQ5-i~Sr;rncoJSMEVlMx+H$OjHj6o+ZjdbkOHVrMG@vH?X|;A#2Y)RL_3)>~Q7M zm8gAda>Mp?miCr2!Mtl+sq7gS&2-q)O6{fLtddo7`^DQAsMnP=842N>vd2XQk{Lx?k^(Vf)7y!L)@n#RttL%HXivd9X9|PdZLk~Rgm$$Yy z|15_w2qNhD9A#>iAKM$1O=iGK0DwZ6fB^%L0fKb-pNeDbcLyHdx5kqwDP} zh$=#=`%HNXptj-4rtLyBLX7?!4`LSuIztcJgPJott_-0m*%A?k1{HzedD-8Th|)Ea z4!Naogw+BRr037(BL)@mIm+C?FdGh(ZUqBZgjKuOmxfN}nz;^ZgLkL4rYXGerWiY6 zNxl>8YH=ZXt8jfggnfg9DAOy;z!Wl_r2DJ{CHRQ|?KoT=0%bV?WIRu_kd7uX;n~N& zL|_qC1xLXkG59bNxE6|HF6!bvs(qHvP{D%Ci@&424`A_9-6gZ#I-u_$_K}qDJDvvbunP-(@6Ga)56^?TL z)fNz#I9W@5E2j}0jk%ukXZ(}_iwFoNSmZrXhz<(;xkny(1_00C z3V`i5J@~-i-`d)G4?>qkHwa8M=}G^}VdapDl67fGz@9j`D3mB0;iXXK;ls*JA*ieJ zo(ku&cWHW1C`yBrhTqQ`_7l&;MnYu8ERTZ06y}mQ;F6ijTq#pvmAzFk`#1NIo z4q&Q81=wqp$`KuL+C;hH5jVi%f@z;QI*>+EjllTdF1^B=V}C~ob&(UlKKpeY&+t+iAlL=~t=%ZebC&x;TN@b&7UautB@qUW;Qn40~+Rqzd?Ko@G>{9H_rGc_Ho{R9c4erspX2~JB zc#MylPw)Bh_yy+$h1YNbO2dJj+0uSCjX0?gfrWDqxQUk29AOBwN1pZCn=#Nxe-3ww z=j;ktd!CHbxf7Gz-3imUvlBv8P?b%sQo`oP7z1PZednEbN^g)Z%L!>%iuLuiCx77= ze&OxIFhqlbZU|gYguJRhyIl+bOnya3-~7-+|9o|A<+lWXb?iWeU=y^}6jY@K&68Eb zl|Ibmug*K9Tm)$J42ayb-Aqvi16sLoKBzg@HYC8ppy-_8{La82u})EjcR7epxd}NO$Jh~%QGOa zmXLCC;QO~~6`fnC&2_tHo$*CpedqQo$AR~Tm34^~jjqZ#;1nP&5@mVGmKb_i-ktOK zeF~g<3H*u2&F4JdbgmZmi%E+D@uM<1D{ntL?~~tNU#I;rq)$nFl?wRy(c?ee5MkDROKX3@dP(01Ui#P0uU<>)viSAwH-3Z2DN^ELm;_OiKgpJ^SqE-umK;FFxv~ zbFcOGz_)_|fLjwWzhSuJ&2M_>->ob!pJ<5M^wQWo4n=$aRTS-123km?%mB=FkV$+~ z;ImB@P% z@e|A~EXtPWk|(~P{`>DGbfBeOiCWz-p7z%gO=x7#0>Zy|Tp=*x@R+){Dt440^BXz9 zJt4=#Q0Rm9%6N{WuM$?w8p<3)YM=BuR zBg~Dbghzz&CS>?(c%vckgRKs)k-x=8mvheFQH^m}p+>4m6uM6XfX@+b!l6tC?XpdQ z+HBNbM}bFa+NQ$d7p&~l_);tEB-PpP5&|R&lo;eq(@^#FcP7Jexn`7$C0-%ZDq$ey zIxg(1P{2GJnk_AM~HpG#) zj?_)}Wa!5Lf#saD}pdc#CWstR`Jnqs?6IQ5Uuw6di=sy^*e@{T_#OV{JV{)EA=kr-kk# zAveALNEoLAi@w|IQgC9^%Ij1k0AYOc`0*$?gq==Y3DGBjF!71wru~Ig%I9HNLAw}g1Pe~YxlBx{~A8igib4_3po%+X{{s+Y>Y z*{2vjq(Qg`A@=s241v2*R&Z5JpCqY=6?%@2e(Z}o?l`IOzVJen?Z!At8{V=OdW2*p zczdWH&;MCP+szym3LcRo01c&|;I#1O!qW}%nYkce zWRi0q>X1N~!=VW4I|LYBDF3aXv5~6G*=`=?PSAlW6qyQE<0}S$!icnC<$%Mymkfs5 z%#GxzTg3|+L1lgG5&{?(j*Q4MHg%~1bEyy@3*m(#V@`s-Fc`8BrYLfO$`V-TrYv>t z__uSYT+2j2c*;|-pxOMmKDk8%5Q3lZVU!3)TUC1<{8v300MsRG;DQdxH3`FD98hVy zG@0k9nFobU=d|#omWM;+d$)u8d=yQXZ1KjLmW+3NEcP%(0LJ8vN@9 z=I76uu9VW*z`zPvoBI`C7dzY}(&g_eTD6iTU&J#3)W(HwwkaP+2fD(!yK_g9A7-TMnlQ zHkuNxR_3EoFx%IHEs?u$R@}iZD2@A1jXvj1<{)b2<3)suy})mbj5mfr)i?=Yon6t~ zpJJuUeQwrIv*y)AyTbSxr3wO_H4zgjl94T%bzYJLD$+v+2EqgyZF%wb4N??iAp5u~ z{}hBxQf31%;U1D3LFr87i+U(r%v8)_(HpB|dI`F6qFz)11b;?7m)^=;C+2rusetGM zfjo?J%3PYLsnP@2`Q*yTEuK62oa}cJV&5U}41naDPMz@k%a;NulEVJ+`zT{%cRWk8jy>p@Q{^tTL!9zvHVxDiyP5%Gu@ z9);Kti$@az2DY}tGH?(;kg1R({oJ$9$Mja~-cw?)Af$O)hkZ_Xgy)ObtT6KmO=BQ5 zxe`6+mEKtq>l?=1g-bQ}IKBmAVjeAceBWv?jK(s1+UlEzEbjjVqfe?uR6gK})a@$| zYQm^}PC9dd;AajfpH!E;vNksAyf%*F#5o8rF1n?iAMr(aZV}Ql3?|FdK%iyq>V4h0 zjxe}DyQ|SBVt0UAI5_v4ft6#~JOtl-wg`3vB@GOF?H(6i{jLm_>B!=()_Xo9HI({N zf{XXAPKO0O58j1+z_c-cfM>mD>6?M^difHF`P@I*9ny@K`x$9EL+g=F3#SGUWi-(W zGA!%7QM+nZmMcdomReL?tLM!h(d{ zcvY-HYkyj8h}>&cQ447+B>xfRc~v(&3MGF9#AqT+6fO^lue*b>0_3@uRtN{(LWJPS;k4=Mrh0 z5_K`aF$w!hhBP4+DVpV_6$!r(22sw%JYBqu!55wT3N|=ymGCb+CalkfTSO<(5PJZS z6gBxWCQ~r8uM}ZfSsl)u>y2cXLxeKSlhMQ8FJl&}c`^>qcllVNJ(CU1^KpHY#yK@z znXiAI%NsbFqCJ%z`Re86T`ok&;v3idtS7hgYGa(==n2?c%n=NZ7@zeF@AdGt{RzT>vAc<<@YG3SNyj%9xwOepR6s;gRvMDKR|R5 zgN1wJtl(I*bx58KV|KXDD1|wi>OMC*eYRf8cfw!@S#s^#wV!$HvB%z>AEW$V)0Duk z^$36*w*23v2k*cCdp9;W{Qw z1p?lpce;KvZK5;rFk&mkW+Lw&z2v8Rs4Il761tEJk=9?wPn^(w5t=ABKriOa9_4tO z7aidj-t8URD4l_ae=r@bR`n-Ut@FHQI_c0a51E#+3!9lt+5| zituDEoAip>$!&)@?UTS~O1ou< zI>O*heWXemQ+kgKfNba^jE2@Eup{gMH_;O9bAlZO^i&g?FxG?+&pk@t46Z@`IzYV< z1v7{+1jP8^!_nR^AGJ79u_n>Hr!^OfJ7m|$z&V+vk$pId-Y6Vj4aQzdXs=)Mm&Sup z{naL)di%}yPi3wB)xdaBg4vliV{VmtNt*Eh{CU>npI+mxuwzjeqtde({ZNHWg8PI4 z0AnI{%54D!k@x0f!00g6KzAF>L!Ik*T45+ADH)#ubcf~1_V)HCAA9VvcMQW|USl@L zYqmA;?PLIKKlIQ;|Lgkd>bC}>b!oWc&O4=G@rF@!2;mA~8g2B+`BRHva?hm;S}QwA zZyKdSnT=|1DgQeUUqBIBr5&%7X9?F?EVE%zbnv#@l6Kg=v-ORYe@LwmbljWp7l#L- zDP=Ya4B9@KUup};IXPY5WmU9Z&k0JIf9<|#TVh>55v@o!aoVaXhyr;Xanu@xgDF7!}6Ycpf;0TjtoQfXQltzMl zXN|1M+W1_XYyW1atQn)pYT5v>DihrikWT>3!UG^#GdXKb!HVWOh_NvG)z3j`$WE4K z8Pkk&d+nBKY3#EG{Eu^H3{@CGUC$#!wMR$Zbuyd4-$A+x-iCCnd~eGWC&oy%AQRpC zxu1^lOyU%9ri@*hn!vGNI@Nka&HyaO`UYMJx^RPWR0PMtdS?+?R3i3V3X zFaU0LBYy$D=Mq5vyY{As9{j1*mDR5kdy`uH4#N)hpLn^+;^+MQR7p<9CM55K+dpmT z{?(-bP=;KR&--;Y1rADr?9s7GABHY{FjxS^WsVb^TRJF|5Oxly$M}wF&2a) z5Y_TpV=D8UN*(6s5-=H_)f}dW6zJR@mC} zTs^bkEQeu3B!Ib8q(UBSDEZX&s{7|}<{%7ZqHUX!)&}h|cGin$3s%oCEL@JMwHAp} z?&_@SX_1l9{+U$8RBTF_mPkj%o~&d}*PXek5>Mj}W87&i5g(;-fI=4*M*IC1jW@;v-$O~O@ekcriKs5SQ)e~}%B6}i zMKnVIy)=R+;DQl0KoNl4&;}LH9b=-01|+&fw>k|vH>)eE2(yt9C5tPG0SitA19{sC ziU`o_xATI%z`f?IRjehoPBL2B@nGo5!1V!~=dbT@rfCVd1=c#!D7C;X`s9^)g@KYKfsJdX&jR}dPlQgb*PtnkUk;>X2vz|N6$6*iCBkg0t-T)%NXYyM7?xzqBM`1 zdD8@^0%DCiZnt)`*{Wgd0*5jFAHX;aOyJahI;GXRbskB)QTWb9E==|QIX>a`$T z3WF~UYDz8ci%mndokXV5t?ZMA^9`4)*_fCPurfmU>F#$xkGU5M%bz}yuc4Rl}l7spv}_t z6t+zq0tntB%4(vxKIh$VuJ6V7PM<#gTh5+6`$*!?Uel$3Fa5;6bS-)-f9VoHu6gI% z-~JV!T3TAV_cEd4%wytJnlcu}N7pAdPM}ru0PXIMsAcCpy!fs*ZwVhYU|vo5m02Z! zM>+v3Jy7K#wB*yZ&EAE*ImD+4#PNm<*Jz{=Co8FpcmAjzXivVB?&_+_?E>|BFCouMKa%MPu1~5!V9^W_hf*686nP)@ zym2XuyO~pxIE^}&bx-q57@aQL6i`bHbnx^Okx25FRDPR>wq*EVICni1YZ#m@(d32w zV&K`0hk5fNW>M(&-?lKg4c$}<@@k`1YdcS3@XP2yTp|81W5*DXhnn31z25QcJ4Z z$)-G(N&bl$9#Pcc{J5K4$OF!Wqk@qm4;p95C&|rH838%hv15@sJ9{>av;2Q9K@JyX zZ#Z%MgbW4I49gyi5^42NSqf@lz6ZdQUz~dp&W$j1ZF~iODxRb&f3H2jON^&8XI>C4 zBGQ5h2+av_w8QIKr;>ZAd^m0_!?)jOI_Zm&$VU0aix=PV%rnpYi^QH00jP-H8y|^R z(Kl}XbiRrPU54TcfW6Jl&3oSR<~RNF((=;bE0>}f44Yf|GSxWq-ga`u?~FVcg`d|< zQ~_(&xb}OBGLCR~sE*j!lW8pGw}C_mT~Y+gy#bXfZ#Udx{fdW4TAthlS!G98R3$qHpURoXQ0r6@{cJ<=RRhn*7I6u zM$VR+PRN@96`ZkW7p-e3qd1f`g$}_>gduTNG)eWRb z2+sWlNMqugqI6Sv%j-^-$!5%^Cr+I`w`X7RY>YSdR~#)H0P(CVtMTO^F8)G`$VK#CT2=U;Mj2t zfTiJu(`Q-`PmP+0YN@{orGC-Am$Qix!S$F2cSnuA6vhaVN#+}F0H>~lrIQnoeV?ZV!i->f#SFI~Fy?x&u5>L;`M z%n^X=`7SqF)>rKF+r|Jmy0P)l-S^%5%gam4%Tpy1!eGvebAB*#MtBZlzG?=*czOhc z6TYCgx{SKqPG72jmXN--e$xGthQLBsuX7GshTr%);W-2$G`KE%@vhJ)awwcOfdHYGDPoDQ$smn)^4-TN7=mBqw1MWJ}@f z1n7B~hVi);g{Xl5`Y;U=fWs(PlfrD*Y}F2 z-pYgdMo_l3DN1_I6Swh04LRuli`kx6DG!<`T@6yDQE1s_UPcdOB#frx=8tXloTtjd zb2FNQ3F8`6tayktwTVc;+`Y?tGarvq55-uHa}gjgp5JA3N*fi6L6p&J>)GWoyu`lj zA$O(34;|88G9XQL{(8vX1j#8u*OL2SIRbFz>{%&^ZrsT~1Q;fZKw zhH*oa)Qk7N=bI2UYfc0pL`Qn1*HHtS5388T9Cw7fAoX$tT-5`%Bldq9>**jMZ+K4D zHRjq;$T+u=1K(512iZv4$JmiO?zmG6@ci@7B_}{(UWF8hh{F1Y2%ubQ8SBtP?Grqp z?zz4hPLj(%MLtyejEv-Ai^%0!ZNe#OgxA(&?6DVu+alxum0!Z*m{_biNhxvcK+PE6 zHbgI7y7ay$pM3I1lMeX60JzS+{X%}k$~ywE^Ts#6@hguU-ueYL57sQ9wFn1C#xEs7 z3FD^wD)e@=GF@6;(V?#4T0BEBXXWXOO%NeQhjgv5L7nqC8*ci-Pe;kwJ5XNEojn`a3(B0yPxZb1?y zOHn#nUsKt`9Fu>KkSBuJv%8N*xWTEjQBnbfQzhjSW5Z4n8W%Eoa2!#1Va3loY&0Iz8RyS)T7uxbs zIwbiK*jpySiBu1Gik%*zXj=>qJ4nj{)gG;0j+7Wv5fvrn%!@fSY$EzL2W?rCqxk z0NZbR)0@8j@S&}La_Q2AVQcHK6s_`B)k} zg~ve%6M+$>JbP+!x}O(2H44OEzDX{VlB7j0eS{kk7)mbXb?<}Qh+OH4zRNYq3p*;I zS;!81+~2KyZ?uK2w5xLWbM1EstC!1~Kvn~5cNEt8L(EvYlyLFx^{wBTBjDtKE zeTXA#BvND!DO2)y{(Kq$o64=@wO-pXXfw|sc9SH@k<-`spGNSQ2MLn_n3t1+U^($TiX3j%s!wv~nh8b@b>F z-JdguK?I-_rc-b+8dnA(+!V->H5&#pBJ)dLGmLVsL%!XW2R+0ezv%zB8}rCjuEF`D zZ8pe$A`a7Y-F{uzxbEUXCku9xws!P^2HqRyTCGxS5Xm!FaZ9$C!ToX zyM|$)+yrm$zySC>Wzenox(xtav1_k?{p-K^@S&}LIPx@{L&|Uwi^oA?!6P~l3rf36 zHkuD}6)@VIP2qtoq)0;FBOtot;^z{xB)3_SJ+_eY&i z$62May=Zyr*CGcz+R&|$NZsUdXF8~1X=8-=h1V}*B{2%|evhI!uZ& zBj=J(ZGbT^YfZplK>I2Wl5pMzhj-yU~Xg0 z^_e4tEzc8;l{Sa7XV0{IvFDgKp_!)yRG;Sw3ng>3vEhZswi7L9lh+?1UfV6RR@BK8 zF(P#CaC5(PRlM=8ds-+hC0=^i|4=+jSpgAe(j{)=JsYCCy*Juc`B_<@_Px?{$w~^t z`Q(W^G{02nU|=!F{Ci5u6i!537z3Q}Hu?Jz{%6LDyODQQpN(rd0?2vg@9;))b;;8;$!R&bKQvj4x^x8Yw?C zDS0#B!6dNucFV;Ik?reS**!94?cj$1uH|3G`Fc9(_ zR)vMkFoy-5*Hw;EAezfrgB##O-VYWrc<}>_)eiGMIi@*pcWQ}xV~$;($o<(Uami7H z`65-&UKtJ-{cDR5^!+QrlQrqScgy2a7AJ2g8kvkr^zxb97oK#A3?kz%Sg^!ebRr$* z1|ZkcaQcPFM+QJK%g+>jfz%_zgiFiAQNVrM*A$u$;2n%`7VU$)jR5M6eZY~gF-bzI zkqqegY*~tOQbU`SU=8HAt{l;vltwlJE|t75&%k`~9Bx)Zbcm3tORpsO{7m*(^Sa`( zDDA72*4kKC$dbIO1))-1tE;Lwu#%eiEc?o(9!l@I-{@9H>g-Z9S=yl_ZnY>ThnXX9 zP3nSikv~_kGLb=Cdb|R~29laz2i_f(FN@4Xj*-E2`XCcmE3+xe$AYG&H zYgElxRFo&9Zf1&<$(jL-AZi*^MoC1(-S1pSA6h?1n^10?6%1SUO6Q92_=3{Rq~ANTNnp31tSk@dX*at3&oC<2V87^BQd(1`Cw6Qd~z z(9kK_R$=UieDO1OT!LeNY9S7P!p_ccFdRF2Yyf%e9_CTDxjkIIN{6-DTOA{dbV`tR)H!Mbpgi-!ip}i&k@1zE zhp>xKt4pjD0V2Oj^+W*#y7xQW0g<;{SKYLc;ut*`PAe;+bQyc!t6}({aIM&1JR4qS z6=+ihJ2e;;MG`;!=kJizNLSteT$6Ld^Mak|0<;^!3eR6hn#4z?buS+7=BDVdnGc@x z%$eRM7Z4tboi@Hwl*7_+3^vE5rBPZ)X*$YTp{h=U++bKzH#W{yl7{hG(ii=CV_#{6 z>Vx1{{ENtt4C1R-_Ti+MJopXgA+FE;?QL>y@4WM_;hxvMPS1=H7ILJoV|8fq3N!y{ z?s%TpUG>!& zKy*iju7I*;Xk_g|MzH>>0)Q)n^1Zgn;*8b3Y&o=_;bgG&6hBiA)CkMvdvOoWX5{}d zuEOw{iuAD&(XXninrqj7^fRCN%m+&qz<~i!WbGI0pN;^?0J#7DH+;v|)}gW7B-G z_S_fkK2CXj(Qfz(-Dp2SJNN#6-|fn4{17!{{IVV|BQr1S8j~OMES$Y(pM7@tm4El| zhJW_Y|Hbf6{>lF?vK)-w5E2t3uKsCZG1B*A_QzuUx z1E#0l?49>WXN)TvoLvdPucMH=doogRRGj-qjAGCm`0V`@k;uSo`4!;M?SF9U)Tuur z7O7#t<2*0`Zsp;)$!nPf5Ch=S8{Y7SKYRGl)?bW+WfX8K_;SIy5WBs17CS&~K522v zs%JFk{kO~S6B(Eas2dvqV=G}lhoTBZHasP65Z|;4f@vd|sXS>sAz^(JL2$DgrAD^4 zvYxr}@6&#BRR#dz@(BIeUG`kn@f#-*C6Ld2?sJL)yyG4382->7{EvpO`I@hpji}t@ z9ys{g!3^9sGw|%Q&kcX^Fa4$AAN+6sPXCg-Dl5>oSr;d*Q83DNwP6UZaEP_Hf4? zC*_=-b~Lq&RWXT74Z)Rw;Q{D1f(KEitP;od-nc9xUUUr%8gW(CWE*AJHN^yYIa_*5 z(FfKAkfT$+ju7EpVFuu;|H9VxMZ|cexKr|Id8qcMfm7|Ba&| z-!gCQResW6o6iwmpJ$4{SZl0J9GUI zE1aIV6n;mJ9#0(ELiK^mp4~qL<sxr|ptj;fpd%-?IeO=;-&;Zy3Cy7 z#Pe6PH5F|K=?`N?ftzQA=VXk>k0niWq4?z<$WZPbwA&*yXl(eTjE^WI*p6T+WDU6Q zvx@UXs<^gbX2oq!kNGW=8b3gtL_Ap9U2(`^MK9A`p^v#Q~nVV@SueuySlSYP- z;V$x|s@D}wXMk6Nd?yZClogJ1Xp2I|+=cOpSj$%8u+%!*{URjstMEoTPk*i5&S*)^ zS;|1#89w*yv%}lI>}|vM{I$O}e9hN=v2OjwDjg9sHq7#Z_+t92lhvAPyR7R+ zmOgU+bU6bo7Pa9wTF&};_u{)oo)^bqsHa7psRGgfQKLNuMfT)LptT8We#^Ih%kVdU z=m%O}|3L8{sJ(+3*ypSjBjCUM&ObZ+*x&uT!<~2DX%K!WK?udDI#Mg-J+%V{WDIz) zOPPqm@i`s(1Y$mWrjbq|J9tm!ZW&DAyMgHOZ|fMduZQdEv%GK==`6-U3+K~ofC{Ko z?01|zsWBl-)1}bgc=m+-8IKqeGu%#viMtRwg;7(o%`urwXE=V8=#DF(A_V}a#-=zG z%)Ma82ZqGP(wM+VGe4ScdS_?nU!OX4>YGpz83i~n0A7IuT~h#89(dy$zi(r6^F!kr z-U#bNh&_NXLayh_jDEqU7cX0fHVn8PL>5y6KmeB@g$U0*BMCyK8h}R#e=S(EK*dMH znN@|=c(bcJC%%NU_bHCYgcQ~!o#D{E5)QYluy4T|H@QU1mO`xD>sCx*ZHU4J1C`*e)2z@|C4<-rWR+GoI#0IH6@ z{u_RmxGAW@XvTFbMUI{aR8vI%ddRJMX$Vho+h=_FOUX? zA_Q(AIs~eF=e1}N+$$8iyA4ML4_GhRZ4uhhTji zhx@&*9s!YAM{IkfAW{YV4^T3)vynLwolWkA$^HUS7-eV$D% zP=qk=En1}UJ8r?@M2S=OB98108rT3zSyBzIxvtNhbMzoe#e68aFJL^vedMZC#rxM*Eyx_{3BsR$b3}OoAb$-0R4RKbDtZ&<2(Mu@Lj3=YdPC1 zw`C6Qd@uvAjT!Kn{_}s~yM`b5!5a1WOs4fOLm&+y+I&AUdRWq3`89O^`Uf;oi&@l^H@e0 z)W^80anTffVwf-jqTH|>9iwRfB=qm!lS<(puY29=WHdokz@WPovObK5aSFh<*XA`IVP>@n4~o+qRy|nG3jepu9_tiX`FDuJ z6l}9JJoC&m!+YNI?%^XJ`LKR^p!{FW$LL^euhtnr8GZ7TpB%pR4}M!PQm)d;(%nDH zVcvs6I6CmzbG`fSyTm0xs9k+d!jBi(6(Hn`7cK_HIf(y_O(|!>@A3O^j3k%T0yti7 z8_aKUw1PIkslXmw@f53`BKx$rDQ+uT&x z3Trh}UZW9t<8a6zoZ)czrRyaRc>hsscv-R;X>dxxjcH?DNx!nZ zs)ig2u`eyFZkNs4Ijy;S9-b_9p$4~x_Uc2)l3tBGSQrSCMaVRS>t9U{gn<(G9apw+ zy&w^Z20_g-PA`G~KneTiH@|uK#3z2PQS{yzn{ewl$H5g3X5h6x1DqQ?+~4uFUpKsX z_N?;OL=ll6QozM?H!#qg9i%Nqct*r9OPhnHI~rz1yWkzk#7L1+%YRwU3vwzzdx1Al z3R4ZnsQHgp$21y37MRw&;;3j3Odo=Z2NDUEWCz_!vl<{3x|$-D##+^-kl~WwGp9;C z&kXf;*eva3Yyr<4!0bo~yJ?i82cM68N(12kId$sP?;M5!AcG?S!2$mod)+P(fSrdP zdhmzW*Vg`ECvBJl-g0f`2y$}K%qKTB5Tg4PRGMxBo8I{HlK9!VXJ-~%65TDR0y7M1 zU$&fn_vX8W5&v?Sj*$PM!&}-;*S2@mMmFx~E`aRM;`M<`5!NG7}T{B?C^i99- zn}=WiqurH@kHA+fv(b&MOrH$N-;r*VGgBqr?zps0t`` z@>^0R5(WdzoTLyiKnxO;tP(XdgkaGy?ZU<20jRhmgZ5C^YPY<(DPXmmr`ds^{dfQa z%>)@yODpR@>ql#1WE5N{Hfk)ZH3$2sU&~&;!tVh35%C~RIgOm&Xa}SL@N1_|o%*^o z0BRM$fdO!%3A}}$*9gGw+kV4u_>q;hm2bIpDeB}$JF)ouk;4x^dc0cu+d>^c(rsEd z;fOH4OG%vj&%9cK=at7lbSV1JtG($We>lThS%%Ppj7GHBb%bGk6%b&Q(NMNmI}q1)$m~LZSf9>qD}m>ZwbYRMixi!QH4>I&^5*5wqyEhMj8p z{=GfjgNUV~5smMpM*xDGASlo2L@IoMnuLwFhSQWzW7JQPT~u`eU<9OW(D+ow*Qh{s zlF|V9|A+ujeJugc2?+4O0Qe%FQValMNBnQ^%is1J|JL&A>bu1GU7FN*JXX3U#h7mt zb2Fn)mD?im5ms7Y{^^l=ZCYA(a=@a^VHkdoKoN+O`+M~^k``HPn?cTV%g zyGr?U@UZ?Pw|_xiJNY~A9aReZ@D!yLJ-dsOP^ki9>TH>P@aKR26T=(d_{LX6`Og7( zZe~Sq)pri=--Y$9n$yqcI&uY z{UqEU0gJh2Kx2PDzOuMs9r7YN0eC37b5TVQWkci-f#Q1ZyUb*yZ`5q2RA;I3%lX-$;I8;t#oj;gBiH>Gw^c3ubc-OWqs|}{m$X? zrAwmSjWB$?Q1qN#dCSch$g({fBbjgKdjO0n*+qHo=Q;B}Q3fK1L<|Ew7{-7MfMHpk z1Cc$HF-Q6{z=WeSZ^!|;4g^VKv2ZfJ_!`d#+&uJ~HV05}4$^xy+;{5xxF>%XrlD9?+m zR@%i@j{h=g)YPWc7wLgMB?i_wJtApDcoa>`b|- z&_A17R;?onF`lp|Vs8bazb7zi?=oAkqCw757XUw|FLvpVzsG$nq;wed==GU80TAFH zMt~uulmcy1SA#_n!wA9hv5$T9CFTADo!g@#pZ@fxhtGZP+2mIU3%}MW2Oc59f#fLz z8C};co&#Y3@)%%KKH3doI$}eklA{11sW;{osgHgut&&Tx3PPE2dCupKr=N9Uug-kg z=2jRk{SfP|AQY=gCO>%fN*6|cvk1g^>)>Wll7!($V$;>LYsl)FJaCg`GUby zCDp0?W8y>hAiv1{8%&4+0@)8QT2o^{a4(_&W-rANK`|4NWW7V)L_`6DLgN<8XwdoA z20Y1ZAZm8LhhY-0@g}oTq4wa};D_rVqmbb?3e@y$1z=#{9xzUbjv~t`Z8R0Id0Q|N zNZ-0cV6=T4nE_1lJFc_x7@M#u}J;+d1h6hLXd|^&PEx4=WNuDWwbpdJN>j1b`HZaa%}a(iK;(Lfc6l z?djpc2Ok*z)xY|M5Eq|^_g|I&_kaK27=H9e|Mu|Mp4;g8Dz-;H{2s$Qg~i3wtz14!V4iGu~UGgRAEGPC4{9zlz1up zuN!kAt`tJu+H9T2>X@ps_qyBGb5N+}BORGBQOPu)`BDy7bzy86hw+sBaLFkTq8o%c z-V`1mEOaP>-WMPG z&>tH<{?k7r;2O^Y5Y9$F4hb@;n?ut(#X6Y0C$K}IbjN2jYyY*zJ?tONe+qwp8egOt z6fxY{mQrN>?!N0TUH7@?+*_cWlO^MZs4y_cNyZg2G8*S_I)2}O?!&(D3mAadG3{t` zhS;N=51(Pnoo3`^8~19IV+^nl01NW6(Xz9%^Za9vJ@#9R0dNojcqyCwW&Xwn00jK2 zZ~f9Q`TI-DE8mD!=g<;XY<*4l*PN45zhe)@*Kl8V{w8csh&>Bi^$?- zegnJNGXTDTLRw_+z7DwTVj33_@*_gPHL4OC{UjqA=FV;&+hhRLKr6qpEeLu@A1`QC zr8*-+$Cml(iq0noh8!K<2qZ}S9x%-Z-v9pLul&`&l85f*B4Iv+cuenk&$kRe_j8|+ z1&)E?=L5_r0{|<2bxqNl=t&=?OD;dE2iMQ$&!=f2ONS`81T-1Z4+}<&p{rN7CFJm2 z)J~L;>jq3_;4|osm{}Tor6tsAhC~zs3KEbM;}ad~PJ};oZy~M$Xhyl042YE#Qn9Wzg=2wiA;mHvRvawmu zi0+Zb!(4Ph&u<3J5`tJk0xsM@WbCc{H_1xM|9bjlLBB4KKaQ{4exsQdjy~( z(!z5|k!|mY23Y%q6eYkwQ2xRVh0*SaS47XE{vDcgyl3`E#UWV<#+ftauV<#Cf*pBb z4@T~tMmkX~VES|_6sjgCXh6Ks28Yi)YtPwcO~C_jZMqLSWM2(q^Q3r0^)qv+>>o`F zStJgHObg6iXHFM6qM#lH+uN;s;Lgs@na3Z0{5KE7@N7l^4h(>osmx!>&#M8j_8Z^& z)_=INy!;IpFI`MU7Nsn6ul)>xIkgoBKg#dw=O~7#OYMpt@}M~bKmsFkXdH%ev=(I8 z%o{q;o10rY=r5i-H}aNdjhH5^5+mS|=L0w*&@vwRz63$@a7#HrO_8VtCj+`V$P~eDaJAdwc8($N2WoD-5 zp&LDW>N6oK)Kn%!Piluae%ox)jG%};KI_GW25({G>i|v5g^a23xMCe5z0h8{VMV`9 z6Rq;>+TnRNJ+{z+(=`W!Lm4Y!@Mulz$cfHpF+gHYvpx3ux=P%l<6b$tt>x@7;a?tm z?9}kicfND@@DKmZwkBgGc5@=m3X(PbUBCMqhhP2GUmZ@IJlRU@K-sI@_QC~?Clvb; zy~lbjJhh%HNXN)^vv+%=o6h9tDcO+B)JdDqG)8K)!N&R0 z%Ch7cBt%4A_zuPm<7@5Ai^Ls8?~;6o`&hA0Dd4BG?J?9;RXSPzJ=!x1^_L*{^0#@{PDww4}Tr#cQ!bdDBkS!r97C*XrhE< zdibMcZcLlmVKs5J@xkub(EIHKE(#zlUW6EmGc4965o=&kyF{-@y}a2dyFELPi|VcS zKme$N!mQ6*Qn{UebJSZra{u!7Q`vK4*AjaU@YWGJfN4JZ(H|ea>Z@+P_TO{(z2EzN z!=L(7e|os@zWX{3AUVKU3+}<;6L2Yl=(C!or(-f`+|tLrL0X{J@{}C_|JNAw&b=JTVW-Z&`b{HDxbAr^?6R8tiQ_W}+;S%S`+V-O7#~Ys`&P z1;e@$B|%6TuhLi`Cp*XU1;wm|+rA3R?xfRR31(f~j6F@DDK_Xezvw6mN?uHxGC0#y zkAv7Q*%Y!`UH8djj2LVxZQG&NJ?Lgt7MB(+kqrLnsERI4N!!Y*lnkji$g`--LD9Qp zPEnc6{euC)W2SHX>C-PPdh;c+@boj!$n(Ed%AfIm;Da9;e)1=OYPjp}yJJo?H5oRa z{2c}p=e0)$-VtB5DUwZlwx^_0?gG^yAfN zUCuhNcX1HB-%zGp{duYs(kTMpz~>mjR6#L5{v9$B5H_jFEv*gLeep#&31VzSz7`{5 zi#wC=aORxK6 zikHJibVyg>=uw=Ydi#XV*A+fT;1eDWYd?TJgk0yfdsigAQt-tlR z4W~|>l3&A2KB@)XxIUrta{Cq5B#qkvLTN8QV$Kwd9K z0CcC;^eWN|*9c{cpCY{5{k$Cnx5Jwra*TXp?dTq~pU!-4hKlQNLgz$VSsAt2!d9uW z9E0l00na8s1$!%g7pQHyl+Bk|QbAZTO8b#m;W`8>YvkEx65=2%g5w43f5XOF- zeY0gUUPnIoJh(R^ZJ<4qAqTz>?X{HuhksZ@f2m=Ad;VPGgCG2(!^eO67fKbv!%q3W%1m3CqI1%-Sj~v#uqmyV{hu`YlT)8TRUU>i{qt+u;V8;xgVc#b= zZvc4xEk~jxJ5oK4OfV+qz?BNTZv)&>iq+NCOTY3fzk&hq)PVsomYrYBzp4SS@!*3G z{N(D&%Ga>*Fajh5lf0r1s`s-FPch14j*I1woc5d-)X<=tn^H!RL+hSDp=ks&p~4Yp z$Z^{Q;-LHqD-*&DffENX9nz6-sX(gc#kK2A6jn;%id+T=htQKyTMP`g19|-T35~;E zP<#G7`UkUEe6I=8L`^v~fdxlX&FP>r?mFvJ=v2H37L;sEALa$2!W07V--}k=v40LLS%wDRT>~h;hEL&L~6XL*L)o6$_~VxVbAv4$fpO zW80(~32iKrb&+PMa(29aVXRK&aD1n%X>N$Q2*7kd#FiV6$7wmv4QVy8Af|E4yhJo& zQ-%lfhq+^q^4{|D@})-~ebfd3mnR>GFh7U@?0;Hs`6o36u=T(L4}5%Wb>+7;3w(jQ zKLumyr;Www5)m)(TXVgCH(Oa1dP06y`FPYC>HCNdq=Jm%{g8xa{vV5RThc2Pq!$GD;!SqK=<>5(IN zw>v7~F>vz8;lmP?SFT>pHN@KwVAyu%-HDz=31NiZWBsn5)2_O;39v;cT2~3|NcYU^ zoJAxd1P@UUeO5plB2(Gj)w~m>ARI2>tH_@=WwZ$VDqXQBJ5tZ#KJVs^C`jp>J(+P1 z0DnzOYEv*f)Xv^lBrfX1qnaRvzm+t~MiGUTVUI!X4Kffs&pa2eDUf%%5JVg>eBIiW z{Su07bvSbLh&bR$fh`*LaaMTWpiSl##qOe@dxp z1MNkLUV2Sd{<`nDV!B7ET(Uf|HiZ>&W3tJH>k7LoYM+tu^TrSRT6ROBo*{FpR5n43 z8lIW-oYR_4lQ{z5VL964bZ|?-^+@C8U;p)A|4o?!sNDb$41k*zCNJ}QN|_c6z@Y~p zc;FwcuCDy%{c<{OaWfYbbdr~c?Q1A)y7xsca^9Qhrd>n;vns!M>(B{5mtY82T%H(VD z_$foYaN(l%?&ik2qD%ETgx7CjYp;J8buZ=ReZKhIg}5q>vpLqyx|fRs8PJZLe2p#* zX*HYiYvHEZWNT$viW|?(;7HC8`C8TezcYIxT|q>@iqtpAH+67{LICuhI<1 zU|w|<>B=+|dk6fP6fO3SCw<)9$0;?BLf^{;ojL)dT{o@SE1=^ldpo)x`8>Z<_lx4W z)UEhX2K|tZ4}9p3 zWrg>#pmnH<{&*<{fDhEq|NPGn?|%1tTC~Ld4;H<(q63nP+?1kxmw7mO@{Zxs<%=?) zpbBOF&YnFZZyLS;l<9Vs4~AqPdm90ko{==yp-rAHqYXb459uI0_*VCfcX{N9QW9rg zc%g;l5p;?kO>*6B4eLfOdnrDT2WiVOt*H)8D&^=Lujg&n_$c>c+$fds6gwinKC_VV zEfUie%Jt4Q(J#)-2q!VM3i2jBt`JuwmB2#_@Ry4?XMn|ipZ%l^ZAojvJhF#_E<3?z zE~A^6Nf`1ME}S3U^{ziKeD`;Mx2|<7a{llCzz2sP|M8Cv_uTWkEYDjs)a`O3qlj`K zf7~W~LFT1l4_hgz7>wu&0@iuojr1qy8ly=BWRAc!WggQt1^iKM!o5!?xR2|=4Zt}> z$+}WT4i!c=E%t42Z z5zM?Aq%}Q%W&7(1#5JKgA9Hkc?K4%~eFLnnkrNN*I2!rqI*w<3X-{;Kj(`%B}JnBw!Ncj3CkS=Dw%VJ zsK#UoM2xhipDF%}fzFHbio7BE8RSq|DYe>V+Vr<$Q5uWy>H|{@hbW7C&82wxjNf1Y z)+S0eehp^itYm89%HxkeP6WUpz=H_D7eL8h?~>I3AQf=zE57{8|K;B9?w8bs2L~z0pxvH{z<}F;1mjlOP&I`?kvyTdi{meR6=8S7GT3<&08i z>ryX^0=aNLA{0F=h%#3{BnkGaWR%ZahvT1IX0Lh(H1MWE-_UjL=f&{6^A|OTYWWToMT+gW(jBw}n9HFty0CvO>h`PqhXz2)+ey z3)hp$I{$e#jqRv%yNa>0yr=Lrf2Yr^!L;NIPsP@ zzxfl(%gYaj_ZXWVCDFb3AoGd5WbD1Qji9ux^}=m~Ov<286j+JAo;GrcD8#AsuV=!K zGs+~CeR$#`P;u@3CImE_B6V?Tq6L{N2l_l0M0-9r!n%R>a$Gz+0)lb#9NNqR*p={Z zdVP&lL&^qrzyL)#ZBP;kVE`OGHvHl*-Mj%{0rvAU0N$;2;(6`8Mh$%N&da7!o;FL@ z>;*YoO)5nE4;7%Pe&s7l-|&)g!2@L1#1;WCp-d`Ra;GTR9&VoUKUgU5HAXX(p#*Lj z?}9OkF-LQ$OeLw(Aor|)rjpa8@H+c@%Ze7*dyoB)C3P?rW_^5k>_i5}Cy^o2!5gk5u#=tz-`Qd zQ)7Ei^EBfJU>qtn+uIBNftd%5h)0wJ))&eND%gh~et7udTUhG%fe(CW`01bdnc>kKr#qR}_cVzv(2|`$zV1 zevlU}!UK3RnK*0tw2YB76oZC5B5jTexUWY2O_Q3*E;v=#vDY0nif8n9!1*{`iX5*j zm~;D&-x*}Ryv>l#>jaTdaRCcGka$I!`?m3%U$%mFtd_AbI8_(coSPRl?9>v@=;!rVDh? z{7g-#?CE)j-Kdeh)dm3Lk-b2ZtW$E)5Hw^NQN9`-Is?;j>lBwJVCy zsKZ*h_;jsZV{HLk_YNUmM~W;gcS%oK@TY>Dk6=01(lQY9r`pvsYtSKIQG+ET!P!G@ zR71)I)V*u0n{d}Z=4Qlik2&fYD`|@pCr{}7lBOFH5U8stj8e+tiqYqYNxe5lCQoWW zXnfYkBcn^Hvu<>;*U1Tg^TQ9{a?k(cAOE=eXW%6hO4lCDvdx9$k_>`4`^>M(CPgiM zRb`7|)YaGLul%+3#qejqty2j>iwr22(#Mt(O=Cq-GC;4X?A0I)%5(5hjuw{iJM%0@ zOvFW#Q7=4rJH<1yFAQ>W_pF=BNY#{#=vN1GYn991P*gBMwwB$TcjSpvgf_&g@$T#J z-tO-1fdTN!>Hc^e|Na_508gKVA8s$k#8I?{bs zSA$2Kdg5VFxLl#jisI|3;za~}(Ft3J4(X6xk@1scGuN&Smo8=*;7f@B2m|o$RtCt} zK((VxQEEP*^Pc2B4?bG1sVC^ra|jLz|Aj&Kw*Xr~_{pNl@clw0y3fHi{ zCgV-yR@6x+j-%zMN4M|mF$NF@Ar>hNA~>d6UKsfd?7InRX|Vjo%;4utkA-tyZfNEI z@H-zKKK$WZ7xoXd|Hm@yALX}r#bLyaQ5Jfns%ZVUA2ahbBfYtBjq3R)L#P@y1-P$~ zFSUzI#*ktEk$KU}b%xDDTS~K$2GX^hJDSP6$MZxK5}>XcW{CxJAH%TSGzRCiC-=c= zK+XdeoJJEONvi!08V*-_d^dp_-|I0mrq)WAW#TH zy3Q3Kiu3BK^EB_Z!{!-}6WL5>>&-j&vKXv`f#A3iB)m!BpfsBE~f^ z0Lk5pYhZVKSSmdj*kioH$cPKth(*x`$yQ(MdDYV8lJ^AbgUn>^0w!I8Z~PR@PkC(_YscQ07oD9a=4eDT7?@a);}bgo;EPI7qm$|Vhh z%CYb^2}2tcMsAJ6z($rO8`^$B0T>uaN`ucaJR7tyQGDWS7wRq16R#gjFspg(%zk|>=Dw|LETnjEVo&GS>>UR_h;ON-?=#P+vCkJm z`3J*al2&E+NB*wf>KMu2>-(p)$m2?sFbn$9&IA4=mkK%1m}tCDOYXeU)`~eNb$UoL zmGu(>V>#$@eRJagDjyl+VQYoKM62H{Zvb3zyQ2*7Qn~=*x1;(_rBNP`-@9UONX$MQK|@;$U{b{RbpV0SuX}oRDi=L z5Fl+U;g&BUw^1otdc0Ov0F%@OZ7r-^2ITgf`a5%Dvf8u6F4~_)i5{OTT$<=z1do7* zcu*!ZDYyZr1<1oo4;Le$B6;hbN7t0%}#I4eTW<=s#PmnlJO5AjnKM^!P}xr0mxFw zUX-!dBCKldXalKLB=j+1%-SemAIosCJ4yvgmJ9M3UM7)^%TQh?qI(+3R@BA)H=HX@ zAR47$fL)1pnXf)Z5(88UgHDZGTQZzm7~4A_*$%^Vj#9~H-!v*{DkmHri&ow!VR8H4 z;;{ew-~WN($3OZ}h5fDkeFhw5wDRGZRqc|XH|<_?f|SsEvDZcstae85>=W2nm9wK( z5vlT3O4vVfKQ-H;WhPp&x+;&~y%AiIP(_2i{uVvW^5I!kF|kMMSz?cJ4W&kAUN$mt z8hAcO0_GIIzZ;HV01`DPDW_SJ7KusP-)am@t3>wp_Amg506dxA?(cX-sD`E z3V3N_W8?Mrz5d=umWHKG7M3=vEHrFsUUb)VD*O{-B(MT2E5n8J5k|82J;rfoEqWRN zs;ymvaV;VR2!AP~l~oBVT_=TO-FPlOu$hc7N7*BE006rGIAwQSn~m=lwK?3;Y@+be zAfL&g^^h3jRk$zl1zn8BI z)AbEwSv&_z0RVK$hKJyM5V^T{F(_zltnyz+=jz$0>xy?uq(cC=?cL$(wJT|h_F3eN zA3A(U&vEw5*=Q`37GPEMbf&}j1+eJ+ag?w z^S|Q`z-sj82-(9+`~>W~e(<8jF9sIPjFJyy6wN6){xpY%RT_H9n95MQbbmx&RN1iI zDas@L7oa_#w08geM>~V%UKl2<4S-k5W_{*D`R6`K1?%T?&Ht=7UX;J(Zk($)gH-y+ zn9jXD-JYHk0Kq&v_#8(q=RA3T-|}#30Pt++0(D6&27+Z|i=u`e*naxyr-=X@7yz?! z;|u@hoB{A}mxiVFW=W@V!^%g{5~62A;(4%{5m*6)S`mutlWKl zu~8Z>fO~Ak_b5NI7ZmmE~BOsFF6A6t_A|^ULk^PJR1xXkD2?s?ti?^ z#=PJ>qpJlJ34(CPBCk9%#u~;O{}s7O5|u2Lmc^W^GnrC5mx4<@!ATb8)2%;x3dzZ= zQ=U-YN`bRp=@~1vBdkbQ7{0dJno2-MdVtbKo|iotrI1nL zS`CSaenmeAjDQm-PD=5jC`vd$`-$kqGfzJ~Jp9gEtNh>l-uDgv?4SMffyP3l38!Ii zmNw3H$O6}&16~HNQ=7Q$eijAo{7EBcX@W?TR0~)1ENvWvEhZ&F9f1L zT2zLqDt5pnH3K3lifq%qD6PSfXLJ$_*%d@#jN6?A`<@tw=!=arS|`4RQGuMRCAU+V zD&uil+giS)2!$xr*K{^5YcUc{@G~-`it|(VzA*s%-7Cj&U;upKb^LYjU;_XQ00QH_ z`|iE>mzS27R<-c9u>lFKMtFyCCyVNE4*AHi`T_~f!ZyaRii;WT#I%`{A&=iIFb^u{ znW=>AL9Xwm&@;oIH~>nBvgiGK-i!Kml!D4!GS6%!j@MvgCO?-4CcU?V6O7A41iC3LOcLYtF+u`8YWW_Vj`d^1^}T|!@W|b zq*$n3POsITGy8+@IuvN5hkF|&I+C$v{UHldt$S(TEJY@-B~m@{N-ur!7DX+a-Pvy^+N{!{&cq~m$viJO7rek|^0LTV&=q9VlQRd^!}3U4@7T7d9Zorh_VnuMzKZ8#(DP=h06J5->a>y0BVaEoT}(qH@sbY zcIJ@aJ5g#D;s321SLgf)ua)mK<^Pe7=+gUwOm86c*h@CX>FNi`&;9q`uU}!9DP^;? ztO#Gm=pscax?DOFuC=J(4k-zgwln*=w*s5gn{(-NJxZKsuEG9n&A>pzo9{zm{55el zPbO!$Nr4<3!`|E<0Ma0 zOGzzW*GgR}vSdMZ{l3q-9Uh+708>ZMc+c`P&QP=$J}>}YVGVzS+h5lJu=1*cqFQ+l z!+|SJ$tVRNOFViE z)T^-x0bRkkMK~2w%R(kl<~4ei`wtGyr^!R**`d;~Bu|6_m2xJCsAr&^Ja zSI+<8hldZ}LgoM7_r7=dsh|4k;lBG`-yDRc&g9Qm`7Dc;Cc)7Mfj{50AK)!0Q}pf<)zhW>q>mu+%cgrHIiV&V%L&ektM;q55>van@LgsD*ylr9k_1o{2Q zOGyDtA^;LD3oHc_B^o2iii9925st%k^!4j0lWhJlU%?Z;)BDq>qH|YjTK8Iu1z0NDCDI%B}W_;$GLX;C5t67ha`3JO0*TmcrKVKq2r<_c=f*;E73PSVdH`Vm{{06gBLq-4niQ%mdkB!WK%l#fseyf_DR2PLy5d`k-$T)%3R;Pc4bbnf zK}&-T5}>MZ_7SkKm&I29j(^k59d+c{i#uv?J4BH|4Bs;m=kk*X0H!9XM1Zi1Wt`%N zcn%H-<_Gn*@tz2kRD(29DzoPeA zAY@~~LZJ^KO@%GMhcS}`diE?`kaSAnz*7KRj74fB7q#4}R!FykfPK zKL~&O_g@;JMy<8n_$Ih%?(sbGcT#xG%zUf@AUBAE;6kuh1?E0Hi^^1rz00|chF^0? zKUTdHcnSy}P9waj^|AWJzp(b0JIg+vsjTfgf?I!2nF9_=D8J@Jz^ma9d8FK0Wj3 zqmQnW0M|&lFXgsN007$X^%EzkXDin$tUz>Sx;_pb+-q!|X>v@DsMc<=*+So~6s7Jf zwzO4`HIB`V0}XTyZ-owj^6PcpGT+@U#MToBzKZKyTmu$|1XmUGt$U^)P*(vViof`) zFD^d~@ZNiy>w*ISparnP5~t^65e|KDs5fnf5+7f=&*P@WB~8$}6}%U4_Y=UJIvMWJ zX@a0jFTGT4V!3^s8PXpK;_djbHh)KcSy8~-Aten9bd~_Qb>>DYP6LGl<>c5jJP$Rc_B?@9@Xh#Nc-ydkC5L>_ z4U-;4PL2684k@(f%4>@A`(OQ|f7HD1eZS5dtd{cs#GRjLp8kwyG&^_h2=d{afN7M; zl|0gX81Q*6!BJ2=TKzdnnqKS?dQwIc%|(}7tOiWSju-ILtslt!h4b4g+Yu#r1K<@R zPfTH5)3)3+aK?H-_H}zQ*owCCg1#VVEsQzdzGDZ^9%? zK>t4ZX$~pS>Rf<9D_ti~04!B?h?4>y8ftzj;;s;QfAz)XHv_sig#Zv%fP!#_hOO)& zAV3%UQ1`Q#RCqop*h`;=Ho(GqLog#8^TR*Ndl*V+jbW-ROla$k@iqjj=e`y%K;l-PrH z24TyT(i}oB$-ekPfQ^=XEURu-kke0DkuZpk^3CBym4^AJ+lE5~g=e zFBuB}w4oMuAqyZR1wU8QkFXJ(yPV&EcV%2sRz}N0jti$IHxSL9V~f`QMgjsxNJ1>_ zHoMtz_R>u*n*Tr{V}s%#DCKWy_Mt$H5H}l+r1Glylp8MbuB8Zwz>(g$ZQyoi)`fun z^IsoWwg-R!;JQcyj04Yki?Yc$Y$iM9w=PcAF$Yyv2HcyK1m^XCh{0+aKk9qvUo4t>P;bcYpAaoSB2{jVy?= z6)tx=$||2{2+uJ90+s+0KD%vkSEKCR6jI+GXUUm8=iU5h&?s?+M!UptcF!YB1v2xY zLx)OT34$Eg8er{-DSCKV;LEOfhI_&k8z8~vwZVC2v#n@dj(K+n(W6I?MoofBz+G(p z{C9d*H*TaC0r$>s|AL9aV?yH&WLtG&T=xJhb&}WUx&Z*N?*Kdi+&IIY>ljMSqovEg zQ9%$|l!yT*t>-hL=0cOoFHNaM00LlR(=~SHOsT3?&ywJ@-D5=fiCj~8nL*tRhn=-7aNxh=%@@9TE39Zs*o6(G^Hv!)yk?*-JV1Ya;QnO;fcx&dulb3ecm;=~*l_)HV}P!PhWWspV=kcJA#}Os@vZ_= zdKVGoM3lt+pM3H-g~fZ`^WNsh8*eNRTd5*gqrJU;+i&3+7*hUw_wMB$! z)!BNjB)iI`U%=z zM9DC0aJJ!ghmu1Za-Rt(cw7rbix3&^p~gQbz9siLLX#lj;)5nXu4GDYCC;D=qXo&A z5CvhOY31#oK~0S@00j4Rs-V3F{+-E(3!UF_;R6FK+!M0I_s$#u%2!rDoaQwMZLnJG z+}t_`-~yk~@c#d#cYrIju+y zL>-3_3z@XJ7@A2D&5&iG0yz(_ps%uFkV#g8|3HoB_7Wc zSo#0}0M6UCQQ79SHw{T+0>=qsL3*kU3WWFYeWVpa`J-dwkt0W`5CJOep11Xkf=f&* zgeNrsKw0MG#-sd-qFQc*wvg+c##-_e40;a+_LISbvn+fG?gdgG=Gou{e?wZTWnd7>lIfIhHr3J6_=y)f-I0U{&cL*&vcoh8rFhQ3(97G^^ z97Hh7A!zVB$I>t+{2t^rkaJ)KFpkKOg1`Xd?uq;;aaBVDR zyz-U*B69EL?Z8Lf7-J+6gzS6!JF}R6)Ia0@vuVj`TK@Okb5BwJD~| z^b!%6-o*$}79+uP6{UJx@~Zr8*i^FDVr;teVqw;7QV3V*LAnE|@-yb1> zRK&-JPVsQLvRM;=Y~}N%0R(QyN_(uOZB63;*2o!LS0#7(9I9ajnr9WNC?bTs{!zmY zXi(1J0~FqPG;x1G~yNSvXO zpc1r5HxvJ%KGTmw%>=mA*$FN_P}oqea1*Ws0Ni)q=bN8+#VaTfA?WaI9zHbriOOR1 zoIp?}5~IOf8Awn0R{((TJ@Ld7&2RqZZ#F2Gel{i_Sn+{;z-C>zljp zxu@B4(H;_JX<9c2*S}M${1B}92;s7{~FOW@HgdPfz4=!Wuw$dS2T+;#1vGQUi zzyJr_%i&S5rHZgv;V^}`AoNJ__F?WoOg_EyA_UWbVGD6E^>xT}_Qo%i!>YPi;uPmU$~=R7&u-W-dr6%Jjd^#~LbtP~afHTIsTPBI8YQ$E)d~?J z7>!0VU;XM=*8#uO)m6O9W zTq1B$Um-LsuEKY5PIm0t(HuQ|gnXn@mC`P}ASK)7zkmwo{ZMNGA%0QJqJ)zV(x#0q>hNoaKtt2Y- zsq-Juez>$knO1^dRotjh#>{|XIg1sG51t`+)J9fht*!V!s000;-P<<%`v~%Z*JOH0Z9e``E z6_IEonl^2r_u_06>A|ymoQ;+x(h7=%cV>es%LS1V9*RHtlRs&`@B6-Q-SS_f!}wq7 zZQC*b8=Jd6aaXhd(o5C-Fxd0npPAfi;}?e#3J^qHC^m%M*vM)W6)%xZM2}@{%7r}- zvK^EaKm+6l0r9Dm0>Q48iTVl!_~EsL>{$%J+pEwrfXHP+ z(<4aK|0EYut;CD`O!2!X8X2nEOa0C`dzfJow!$Q5u^q&XuVHKml+751)X7T=G=+sV#6(&TPV@@Pd?P z|LoZN!K#`tZtSoK?}o~#PZNN^Ljqzh$8UCAHZMD(v_#qUTQ871=Q4!^oF?UZq{zUT|zwsN*4L97dfA_S31fO!zn&CV9D_c#p0@#OpV2=I`y9s4_!}e%GEo{O^$?M^shQ-n))5=I;>{q|`f{aaV%Gcj3mz z?{XND;;YjiAj=Sp5sDpYmNwF?3)nPjUTui+Z@g>H2*BU+Uhp{ANq`srpe_*rcY9FD`d#ep*8%$)Em(VJRL6uPF=etT6 zNA8;O1^{5RpA1id&ETFn+y(%0L!tnY-krI-aNR|1^(u7gT(y>WY=IEf&BlSjfB%{Q zK-IgJ^1&Q@{_~%2UUvQU42d8mO2TfKEaW_Eh6SP}Y5{c_xFJk4LgzwQ6;1OW|MX9r z@BGg1T=KYz<#@yfr(_m#uWPUNZhjrqAFhR0M>L{=^U0eApPu!)9XY(G zVYtB_Q7b>x_Q&sX+h{A@VE|Cizq>LP11rfYH#p02e#<+Pweho=`FN%kcK)5^DlB+_ ztZp$M$Rs?=x&C-hRy<9u*f9nG5B$!(5pZ9%i$|8#X?Zy28-^+;CMMQNfEQQxB8w+$|1 zWo@B(H3QnUG7i9fpTDnp#VcN3y6IsE5Js?uinTxxq;jKd2vgb~*B8#+={!CNETn1u zw3J>ocIk0Q4U;?}cs^e7?>dG?%$ zfhCXD`$;HQbvcZ=6UmRwq^x-0nM|(r+?zw^!|=V{3ji7lbkCkWREXdaWY47^ieg$y zfu1MRKiAUFyqg_S8z5WIEdX%tJi&JMMtH6ZgFd&M20%g9QPex3QI+%iIVbh3W;tgI zzWY1eYxnNm^bEitDW270#3}O$iXL;~ct5Vs_jF+)4MEM{a4oWk{4S`KFuh@V=79$u zSPua#MA)|q*OEWmw6&D!%=Hc_c<*ji!Zq5%>$hE{8CWc4yOhUd<&SjHPkiDN z&Az=6^DkPs3z20+NFlkjt?F4o&S=lMq_)l>_7*H+Z%1F#0`x)ltr<;KUh#RU%TGDG zo$#I0o*840!b15O@-nECAVL(`sK7ID)}S=;UH!bFv|#pq+^QPN-zju3nDB3ACIlY= z^Y8uu_%7s5?z#yB%enV>-cU(ipW&RwZ0`ZRnyH}=3pB(>-=i) z)~1pJB`7N-U*oTEpaTuG5oM-kjsTv7BJRA@vq1siP8FHE=3_AxSA-0)dGc^FmIrV~ zx-_Y85BMqXY@3b+NX0Z}2(c@NvO?;Iz!|p#y^zJu0Hp6<`A>^q(($aY>=-8}r^X$; z{liY>MvVa%wtyXj_B|LauE!0J$3_4c{`T?q$Q$s`E?U(45Kg&uP@`Tx%6!hyJKJTM z5SfS0G48i)2Aee}+kVhgEl$zmYANQ4mJl^sUhK}!fRzce%Bbh8A>RBrCjplXMC8)Z zbHCN`gfl?bl*8&^*u5mUFEqL;bmU+e@>c)ZSZBxCWnvsucHL-ga+}&62+t(*n~Tl_ zmkYe4H4krklGVM^{=llsdQAOgfwE-kC0rSbNGx$IRAz^bE-!Ksrsz@qmB06G;$w{Yx6&$w7Octsz5I*9X^>OOHh|Ngsbwta6eebTClUpLWwuc4*{ zx1XnB|B{ZbS?=Pd_-#oqvm6nSj~$es8{|Vu7A04+OZ*rX({0Ap>vzYjor{gZi*`yH zApU1_T)X+Y9*G4{b{GQ>-*UnOmjEjw*OtZZSWPslP2;&JtW9R7L@-=i7-QXyJPYHh z{xgCmUtO0#NX|=^m3Wc=+x<9u4CiKc9!Brwl}a~bpEUg;`c_`sirENGyvVJ!+o6UQ z582zvRdQdqI+~IerF-hfre{Sz*_Oc%k?EeX?GzKxW8a2^iO0)G`tzauVxH+2{N#}j zhxMKu#Ea{K0T}ak0b>>EQ_}U8Dm3XEuf-)A3(z5UYo<^0NL~Aq@BZ^Bwpbcu3Ij51 z*`vp@CM<{<@L3W#<0tpJ!wK`*qftah-qF9>Y6>qFeVs9ETUwQ)?~U8fLaSdP{rOs~ zz}T(u?;nIA5)|b(_-t_j@9cqDoV%&l$)b3n4pPVyrET5|-eACZAPhw<0&gFbzw-5P ztVA&@41Spt+h9v-Yn`qg0Y;{j#Z{JeQC-rs!K6gM;cMzu2~K*4fyKQEUW%GYSAq3~ zIQoHR_m^V|d2!7SeRqedib}Y`b4wBBr&&UE`Ka$Uh6eRJH)4*iErv`XHyd(Y$;!h% z9gXC}F*o_AW3Ix0NQ?o{do86-4kg~W$SAb(*2s5Yi5;zr%jJ)jAH+Sdl9*Gk3OJuR zwDSDAzBIC)T^9;Z$(PbOe%;Vv;5uj3q4=UAGYRd3w7j!c>q@w<5LL?DY^w_kr#c;B zQAkMzUySI4Vp01u<*dA*P+}!g;eL9gCi%whlC{mJjMrJo^xXj_HLdnqm3(mcYb>42 z`kT5kC8+9&v5ChNZ6YCF_(xCNcE(lUd=HF`2Aha3& z;Pc!a$olEw5D3kD3dPUK5TN;tZaD5looCM(=brwT;TIyaYoA6Rqw3|LsMLamFXJQn zpV2-X6JLhQVa_Qw$nHP^ofv5TE#bY-FFm2iXo){g!PF?T&iP$FX7i!xW`r4*z6fFY z6V666S_@_%=fUn!4*`_a3mztQ!0lv}o`;kEh5}w5FDwYj7jC1D(x043?W7AA;c3dj0zXwLg~Nhk3{F}QNqA6dlo*L}x)6sfghD@KK|+kqgMID->QeXsj{U=u zI8sH_Lw|B|e?3=WI)4ZijA zQ~xRMWvU4_3^A|ZtGX#Q`9fMA) z@AzCe=Rzsj86Rn^m{fPrxxjZNVu8WGJKbiTy=FkK%fH_QrH`f}NN$^qM33r+g3bU? zS|u)(5X9#-c*za>fU-A2z70FVhYo6Jh(lOQ4Q51tWt^xy|7s*l@ALK4FQQO1W;^(q ztUzT3rFK^YMF-?LNE;5t6tIAxr>_$l^XsHATBn5o2i?W6`1)*l=t>(7h*mmTGh{!3 z0N_BW&BRjo_yQb+L~1sHYd59?ZqY-$+3Z<{l(t7cRBQjpzAIwDyYwbidk}3aS5`h7 zap|?AFQuCHx~b1Ut+?uWNgiVs5X8Hu1PDbIj95wmNs-*>9m-yxo@NChdG3&jlEm0r z5SgZU>7lQC8y5fwptkc;cNq8#nUb0%j0QzxxxP0{K|QLP2K|n7=yCqka1_ew1yR-N zJ%ziTgzJ6{bnPGe$3;ybPT1){w=6FQSVK^IbDVGeb~|&t7BGEAGHKSn-^nb zu41S+M*@Zy)>GXVZW@U1aHGmQj}k{IScELz*zbMbVk@6x1J!>c(QdpdmQ9HL&kKMG zik{L#kNRQzH5VMjuzz!lF)`sxCj(gF{qPZ%Fdre=vNACvrw#GwyA_kF2Pu_yI@e>nsdiUp}eR ziS&;YWDOe-+-4M*&?EJ=yl?;i4Ef@NVce5M#RHX&&0}A4@$Qku9-zl3_inFjh`{m~z z#^Xwtg4%|&66vs**_KkCN(@^swF?7E67l=v{^gV-zN9N0qPKMFV8}Qwm6>D089#(U zf1W&37l)#5qYLBQ#90Ry68HfP7svu2J_;~>$Yg>~`EQG+lBflr`_}UP;SDYKs)u3K}CV#mAa8AwX(r8Y!?A!53jXTa0d8qDo05wLZ5` zE}^94o_m&>g$dzAJe0rr#b*Nfd6wf|02tdDAFX6f! zP=eMzL`XsV_4jPVL`su)5&*rF5ykiCooK_$@P^^lQv|s^$;5WJv6upUnf1J~6PdEUm~zBwX3U*LLtTX$7M z%ShsGr(wJ~_@UU~M`gt8W+hZpNQbcAP!)c89&g-}(r6uZ*))zU`<`1>H zHrN-m9{-so2}pHK%x`G^L{mGA78v)-%uH`gtiVjIiBl&btG)o^PQd%mJ^sA;3XWUl z7vOX3qcSs0`xe-Cv%T?n9MEG97@viZg4IgqoRKaFxj1(xnNLGby9vU~HUx<<0BSGU zW|J@GjzjDA5;?Rn8o-B&eyQvpC)~)wzflSC&=JyoIoq&Yjea2_eH0`KVX!RP4QCRX z^1Cvy&HIX9MRThrm3x_hC+?1l8ZC(v$b43%)Xjm?+ZrbX7xLj&sx`HKSFC2ReO7&Uz}Mh(4I>zRW_XG5VK1q`hXU9 zJN`P2Li7<5G}6q+s~lC|Yf`dSq6F^JvNZiPqKgw>ym8kv?S}dz%rCM#L>b~>eu-I6 z4i-TIJ|UD=we}^AS+6~;-u>mao+U(*T{(sPfFFC|5z#!TNE_As!)aB50DdSpy3Auh zHlb)?l#8zb*5O<=sMhM^_!^h`K#3!bNZ|7Ng%lNjx$Nskl+kfCQ!M+*M8Q9+r91YBYF>!4Qb7nLC!9dSZO*gJQ4-V z7PW|gwYSr7wrw?4-)M?!GE!tC6IB#bBy=0*|G)IOodUuE09BM9pkZt%+7J1Y&Z&-t z*i4A97l*7s(@y5BC7oF!DY%lQ?Aw;g#6};8%+Pshsc#w|^rE`+!t^4bsOF#OJ@o+}XqPm?9gIy_9ZlctX2m#RU#XaNJodHR`{I?e5AvbM8 zzXh6Ru;^~GWkj&GmrElknbNslM+YMHU-qaUf{z`L116rV1ekFvDrd--*LxCjMwN7{Tqjwjrl#AfO6iZ2e1BOHq6A_72P3$N`@b*y<%2AM^+Dd*DP6FWtn?>ic3B9(Z@x^X70J`=^3?bx>kXO9JW=pEX z)k$Jei=t__0-(h1%gUPhz*oje*XN?fOY?28$B!xkH9Lfo= zn6eu^1~txTBm%{6OM52B3hzJ_=@|qjmzv4U=!-Y%VVoqw09%mjF!!}Up>1WGrd%Ej zZ$1&^pYi4!iu#{*l!K7oNMb=gE#_|ew()&&If6FG!Z%O*h%(sm26~1o8Np>sI>7j> z7+q=5>5fdb6$CQzU#FwGVMt)aIZ2Yyw6G=H&$?->RD!vhCRE?Q>~JxGa2qAf2L?)` zG19&$qeovpxUzd%>G(>=a~IVO@{D9KcG z11@{)oOXKVtKII}wM+8PX`q6YOV&m9})ya|-`#RQj7Z@uwjq z_U+#9@z^qXp`6orc%+0$b&O0&@muVIjf(FRZfKq@6d(rDFB0m`h6jbY%GTlBWabv7 zo5BzJ3q|=|^R83M#kgF$x~tObw&0?fDb=DMfs~!*20Ecn?0* zklz?A|GX+?k51q%Gz|%lIpDjcOUW#Q6kR|xfps3_UYT)zJgzs_ircjsIrSC^t{!QQ zM8;~NQL3MBCn*6{$`g)b-Tw$8M{?05A{yfa&e}N4VL_ed*P1R^%G0sYL^T>vrgbg-@ z3`EAk=?MXhjmU&*H7d~du$BUYj*Eo)OMr!2Cz%d*s8o}9sn`4M>t(Q3=1YBY}#bTc2;PI|}d<#Q-V1>$9zV)L;g`7U>>?dsc!@4fR zhtFkCxXWFCKN@LF)7NWz0O*orVJ6WuimbD%;uo$?LtZv?@AFc!n zG$lrp$PWpI^PHeOT^ogf$Y}v!XHmRutkxa^D2A=f4L##1M^}!0Jf;-V>9ak>Q5$*L zH$)X4f59m3jtgx1G$CTajDL-Capw|1H46^_7FRB4kWoMRQEc@)bPD*!lUHo(>$xJ1k3 zsQp$ijcCGgoW(V?Zq}f6gyhAD+glS1zs;@MCM=TfBmBlQ%MM;2`ulla#Gam zri>FA`(=fuI-h=cTllFnm)sc|EJE6a8#iNy_lDLF@wv8gTw#S6*V85hX^F{>oPq3i z`wE@qp~0f>fJmZ`j?>CoIt^(lqO+g#T*_D17V>r4BDb%5G9n{CaG0#s?q0PPZUYvw zwtl~Yc{=`Xb3>J9#uGWmmbQ{jZpWyl8;RFAc6;vXqkw19M3jQuY_4SUy|CZEVnE%r z0o5DQ`g2kf)?q5OzwL9<-YFt)&!b^A1e02}RW?TTOK$Xovk|#@Lbg(pt(&cFlgWTN zm=!vAq5^on<6hF5yJOEtguw64xvd(7q}S)UpbRUrNP-L!P;-{Tzdr+g2kO}Swl;(k zhUH(rGYWkqxFJD=hYG>ezU0|Kt>ma(5)9*-@5hk{{=kHvO}DdXBbY6qC1u4i)R^}n z0hk;tmM<$wfu|NLp*OLGn9_qkkn9RWKG;nfYf-$VDNP&2Te52#mTgrEv=h^m)9w#U zydXijMg0X6p!bfbLUc2uvKgMSSh0x9mip1;Za~*@;#+?* zajh`|LCL9D0>$Q>E>kr!BI%*QIwjp{w1$NSV@Y#qV~VpfSWekM&2S@Q4ixr;9|GJD z30g{`|K4BOY!;st4@e8m&VcQ4no^Km7Z2-Mo;_y<=jCDE6RA6}_qIPU(8I4yaGK(d zX09N#gdgz!ef=qB&Oo=2=^KZ&~-Ti1jNlf67v?--$maRL^pl^ zE#x^qoNp~ungzDk%Wog1qZnSZIBo==84DTv`$}WK)QN4{4NQg}OyT3fCL~4kKSIL- zZ14l5xt5=GI#DLfpr5fZ+^*?w~iu@mcs%`25zbxpWW}3?cIRIQ< z@YkGOk^e{z4%K&qv{_K1e2Yq8?gJm)`FVCPCW|jQGyuozq|Y~XwdsZUN)G9?$5SST zLBZuL<9GO0y<0MqHR})q`3GGxEu$M7|LLZ+>5ztke|w&Ce86@y;7(0_dZ;wrBj%$| zz;c?&www`IrrZ+qGTWCds9*u<)DdMasEC;zPv<~GHRq490r^jo$hn6$Y6d~*(UuJZ zuE9R5_Re&FF4jLcnuy0*OP&W?V{TvHOYO$pkC>vMb{wtw`@=&+!i@aT3m`>=fL9U5 z0S^ZsH0sXLF*+Ko23e*zHKJKF&+Es2?smcW=F;+7=E;Gwak5Ys6QD z^NJCatI4s8<_r;L67w?pnkVpOnJrTJczO&HnKeYt;qmdK4QrPZ0S&chSVCg=yNLg} z*WIin8uzkcX9xSoa@SGV(L&z4`dy5b5EX~?EXAc1g$sv6nmYK*U-1ch{7feX8IY^7 zd)=pROqy?9l~hqMC`1-KZX{DWg@hhkFI{&YXq_^SR3EB9t$a_ij<(sbq7p1VLe75A&}5NlZ0}HKWjQ3+U6+id~Gke z0x!I~?p@~HpKmh-afHj@=nVt^VT+jqiC}oE1oN*djyk7Px5c+9&~3c-_db;zwzL}4 z()-VZE=(>S&jQ3&V)CF9`6#PfmQX15J22ya<30LU!PNB zp0pU%nOZDGT(k!8!X*qsClSf=lN?d6`qtSkF^^NSoOm!FXuieMkTR}KQG z7HJDKh!-nJ{x`<*XV-xuw8dh=5|IR=GxBIKGIbdO9S|ejscpo?hr6qu$;0Cfq>ea~ zMS!FF9l}kS7{5z}@q8_c-UweRa5L55UwjL<>i2}s)j{H{AbU}Uy0@bfr{XxpA7l_P z2$3)(|7LWg8*4X|qJ8Qn3{ zXat(*t1}eos4c?>k_V4CIfJWoGN%iNMe(W#o zqSnw@qm_KR6$rvGAx?&!Y9rQwe(_J0-!**+C znS672G-Bd5X%eEaM_?Q>4G#OGozqJEnN(j}zI@bdWslVH@PePAP?PmVASyFc1K1`Q_UPguL_Z|ElmR*+;k zzS^F(IOx|k&g@t1HHU=q5}tPa__0K~-t0yjNCd!(tPl~E@hZa$)YkdHqf3AW5IOcY z?qXDzY23<6?7oA^z9Z<8{IR0|@tNNyVaQv0wWs9%^roy;sBQrIVKrooERD#BP4_|)5ovy(~XMDeAY`pe#VkkeS> z?L`Z9U-NM3$@!vC$vvU;Nl3_AMA4nj*lm~!^cDWZbK``0N5#jFu~3BSXH~a!mX0UW z%QtY`A#L_cE^uxDd?yIh`SbJ^W;e!9mOg36IQ=f$a=hX!k!P&`~ zDokTJCpDO6fe3{R(}n!$k)>Ewgn=Bq<+czzi*!WqwIoxx`tr9eQS3Ns#WcgSRROhE zP3|L`i_d06V@dTZ!_y_uqFHV38%g8Or2h*2$DYNK^W2W>0A8b-B7=o?% zHc>H&It~U&R9g)cGlMsWg zW5BKWTLLfb~m?opX5z4lwYK zz7XdUYlai;06PNO@9{#n@OuB=A1+$&s&oRGWbBQJH6xVYJ+~rTNS_$~?$_fHY$DVH^kk{^2_QEe}i z#O9hgRv{e;hJR8lsjpDfPwW7Tjk25!Hv60S7nU^$QLt?ve1Y?ad!*J~K!7G#KOUf(nwr$?{jpN- z$tR6tb3}Z?ehpYwEO99p%9WVzYooItvVJe@1W*uYPe}3MakJ8vIHK=HAwkh~GT$r` zaS|QL@qZLXJXDyZrb`M`FUrds7C)YLGVDIkn^M`)~ye;4^p#IfYjF!H1+~|kw;!LJSozTq;+eW4gFQgbyJli)9feN(7)Zy+sq?+Ry*rC!e0p* zm4zBW(kh~BaAxgj>riqNBB0N{t^6s@;!faFBeX9cd)Edefyet9%o9CBHM0)r3?iRS zYQwK^EqXjBPWdQOf3G@yXg$2T?SbW@{8Momm@4f5t$?}Z7fLAUq65Bbzqhm^Kt}e8 zN>;1o9^uA7Duh`7t%0S{%ghk{oxCklA(&kBh@A|59$eHPfmgQhS@XjOT-M7I*iXoA9;`kq$J1`d4P2^k(~-WrY^L z3e*OFMIjg2c}r2tNsE9hGTi2uCGeO1oUn^%;+Li)=Cq~z4wDBTtU3Oc-Gn6&Y6EGY zrKc||TA;!6goiI_ZaGCPM8&w$qy#(?3EAzS}fH*@CN|+({Kj3M&Wia(Z7V8RjoKQBOKtPk6KQv zsqD+P@Y;TcNM0GmqJyV8j8U{o&qgEZsrr_wCPcn6Y6)-kX`U7d@p3)&v}qjO-=2a z#2Fdp9yCxwffHeZ2s0{BXF_NgTQOeFrK4Lv40Mr)?9Yqwm47ZcV)w!|J%S_vNL?4? z5Hw3DJv&uTmFp1=2|Tk0uAHUWri0Bh zP5usQJcx2oTs!iqJs{@qp!A1&!Rd{xG_|1~&lhmgtL5bVp;wL~o@pXF9(;19j@DBY zOOyI0jlTPb{^z5rA=@4D;OZQlPf|BKPZbiq9+ylL$1+5pi2EkI@5o=0#;mThzyXi? z-!3h)PJ?9U5c+4%Q3J9MEW7+T8J1_#3`C7sA-30qjW|vK=oxfA)U_vY-bW!;fkOH| zxe}iwKalq881-`ko0065`lyB(SN#bCB}lgmYk1swZP5CtAiSpm6-X;;(0X1Y1=!Dio>kM; zUvs8OEvVGH)Ldd5(0edl&qO*eaC9!$UpbB3LL`6|D|aIZMm83!-?4QY{ULogrfr9` z2(dP~QdBdGedOSCw^--j{A?xqFq66ce4*j~vJLca=aiJNelboY)YAVD*-5&F=8oZ9 z;7CuGj!h_J%!5#aP=iFjCf}=GAdF>GYlwJ8{tz`#Dj_q$^|H&)Hp%{JumuTd3m(1> zj0+J_`%6N5&}@iG@Qgytej_8ykQ`nKM%W{qW|4KFzUQmAO-qD@>1aWefT9c^M&GjT zoX1PMeY496#R=(Uaj^|tx9M2dLAaRcbX?T!f|tfJ=cp82gFl%&{~TuzJw#WV7RTpEW}2gaf7i0Z7Vm3YnInaSPssLqYK z<5`?4Z&)6kV1!P1U`=Ez_s@OZMB3m=6_tA?3EiW zIQLK9LGR*X%vjNJ(7V>eMTYjV!90t{LOv#z@G`ppqW&F~m;KSA@;KfbO4g0pYa^PJ zewuaTDHZwT$4~M>4-qPA={(9W1~&p;UQi0)C{%I#pK->lET?xiR=wDa4?Brxszo%I zR$p9p%cEB+do|Nb?u{!;?#5YaV&dQtUpf+;&?sAOneWRqgE^oiOZp83^tczku3nY^ ztHo|KNstxgjKvTV;y!yVNiJLvmiub--5&>o(Xm}WHGkjRB@bLN!vS4H;_WM+g*Btb znEQVjvO40y(^UO#UUfs4=~tI*DLA#yL{Gwb(KH%nUR0aiL*B5fXs0BG*t!MnKzZ21!b;4zI|6s5Z|Wj-ykk7zsdp+kk{(?$IR zfqJZiF^7bWJvgttZ8KS>b%t?0;C}R>u77!j)dj0~Z;%aay#_p$$cq_(A~r4-!7- zkEjlaHK-ktJqL5vTBxPyi!_2@l1i~M#eT&yK_JJrN5;qw>paxz^J0TbRerUxY`C zYNfbf%4*1&g$gyw1e0p?>?dJBVeHb{+vY_RC-i4kX^D2r0CEPg$wB6y@iz`$8OAdl z)&TFj_0~GW%Dj|cx3BV3F>?_f$YMN#-|p!KTvwy_{4`RXp408az}Gtu0B7};QA2f5 zxF!)~STm*^^Db3Bw7S`2CgZj@MynGXY{lzhHL9$%Gw@1z?i6VB*QfX2vZ)$kk+Fk? z*CekJtcqLqVWuS4^LF%@sZq2`LcP9F>>{Q1^xLaYs04(v9f3{wk84NgAwfH1cGhHz zPX~~a+`_jSBHVvt)KMjKLjO=-+}ivxyoCJWfw^PaX?^k0LkEl^Z(lk`NRuR=*^?%l zH4-fceT|h;|4r?wXcv7Vb~t7c9XmZ=h)y}S-rJI+Lcz$XiDQ>u5`uz=IX~<1X7i0$ zx4rj$CDtl*XiorRq7;Zk4=<$zjVpg5hbF+zuY!UEdWW9WB>+kYhJA`+mQ*H#q^a^Y zj*_b|dnJq|;=GAb-q09PC2HbBsM!-c~qL=oFQdb09EN$E4Xlm z`I>+JFd|T7DB31`6gfIU>7)WXxd?|5>NB0^^fgDQ?>ou^lXG!J>1R|=7yUR-UPZ8X zqybVl?N3k3;)O>e)141g7yKZFpSrmPFFsVnAn@d9qK(gW=)!_Syd!{(a@&qU&@QoA zU>0{(UfY|7IqZ^YksC}nbUG1bKc)4oo%&X5P@YNwa;SqDj_@3A3R2^~d)oicpn!{2 zZuj)a4Fyb3I1JgF@mfL}F7w(2MXq)|s4qB>mV84B9?1ojN1|$(#JB$`WGU~FkyoeZ zT-_sp1PU&`lOhBo29(w3(Vu_PIk0s9_4NHQNJrHTW01SggwOjAS7(LrbxEoRR0v!? z62RtpZAz{nu0_&@MSVz%_)%;eTf?P~h6=F-HGa?kagfKA*L7Tb2nEt}blCd7p!g{b z0RYkQDv?VA=s_T7dI_G%VPgdqn@HhW2?g4BNF?I5A>?x^nRp($f`6>w_OUD?LpRAg zaQ&LFotHbyWQ~^O^RM^6qKn|{!sgWFW@q#*>yi=r+xg9a9z(EM?T`2lxS=pbXlYl{ z!q_@FuTjo4gj7x72OBNzR)o1m2$H20a$6CtjC?SG=~yseN-`}}t#wYnR3C9;mkT`~ zrHUj>1Ip(aWO0QG-KaTZa|~@tH1Q7RSH(@%MbJV&P6KzIR6UFmCdVRKU6`+O-**<= zs5AO!65tKIQ2mvS@C?U;pomy)KT*a1G^20*1$rvM>~t5=gz{6A`}DV`iC(}Lry4~} zG;&M)Vfg8sIX5rwznHvqOiI}Uc-ugI>RrNj07u(Iw2zf52UreY75{dBgYOL(lhu;N zIG`+n{7NN?3!`;psUv_Fpyk-z6PrQqA8s`;Kg8|K+PRPw_iOaaQ<7=qV!@ZtYR=z0D!f@fuH%ou3bca_1jp+nZwbh22_0{i)vpa@B6#~HU!a)E-O;oUbY)B|l zyLd4B_jM2NSNZc3*{c&HB-k?yRRZ#io=ioF)DL7*Qm{Q9H)@TE@1fq~_I$s&6K z2Y5uT-?wdhUdTT7k$kI{j&256)`s#dqH(l>EJmt1Zep%Xp*wH1kk+@b|%tcJk^Nqe3*yi^OpFg((P}wG^3CzY

vDky&Z7IH+P+BTHvtq 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 TiR= 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 T2@fmu2~d((m#cjFI^=% + + + true + PerMonitorV2 + + + + + + + + diff --git a/crates/auto_update_helper/src/auto_update_helper.rs b/crates/auto_update_helper/src/auto_update_helper.rs new file mode 100644 index 0000000000..b8e4ba26d1 --- /dev/null +++ b/crates/auto_update_helper/src/auto_update_helper.rs @@ -0,0 +1,94 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +#[cfg(target_os = "windows")] +mod dialog; +#[cfg(target_os = "windows")] +mod updater; + +#[cfg(target_os = "windows")] +fn main() { + if let Err(e) = windows_impl::run() { + log::error!("Error: Zed update failed, {:?}", e); + windows_impl::show_error(format!("Error: {:?}", e)); + } +} + +#[cfg(not(target_os = "windows"))] +fn main() {} + +#[cfg(target_os = "windows")] +mod windows_impl { + use std::path::Path; + + use super::dialog::create_dialog_window; + use super::updater::perform_update; + use anyhow::{Context, Result}; + use windows::{ + Win32::{ + Foundation::{HWND, LPARAM, WPARAM}, + UI::WindowsAndMessaging::{ + DispatchMessageW, GetMessageW, MB_ICONERROR, MB_SYSTEMMODAL, MSG, MessageBoxW, + PostMessageW, WM_USER, + }, + }, + core::HSTRING, + }; + + pub(crate) const WM_JOB_UPDATED: u32 = WM_USER + 1; + pub(crate) const WM_TERMINATE: u32 = WM_USER + 2; + + pub(crate) fn run() -> Result<()> { + let helper_dir = std::env::current_exe()? + .parent() + .context("No parent directory")? + .to_path_buf(); + init_log(&helper_dir)?; + let app_dir = helper_dir + .parent() + .context("No parent directory")? + .to_path_buf(); + + log::info!("======= Starting Zed update ======="); + let (tx, rx) = std::sync::mpsc::channel(); + let hwnd = create_dialog_window(rx)?.0 as isize; + std::thread::spawn(move || { + let result = perform_update(app_dir.as_path(), Some(hwnd)); + tx.send(result).ok(); + unsafe { PostMessageW(Some(HWND(hwnd as _)), WM_TERMINATE, WPARAM(0), LPARAM(0)) }.ok(); + }); + unsafe { + let mut message = MSG::default(); + while GetMessageW(&mut message, None, 0, 0).as_bool() { + DispatchMessageW(&message); + } + } + Ok(()) + } + + fn init_log(helper_dir: &Path) -> Result<()> { + simplelog::WriteLogger::init( + simplelog::LevelFilter::Info, + simplelog::Config::default(), + std::fs::File::options() + .append(true) + .create(true) + .open(helper_dir.join("auto_update_helper.log"))?, + )?; + Ok(()) + } + + pub(crate) fn show_error(mut content: String) { + if content.len() > 600 { + content.truncate(600); + content.push_str("...\n"); + } + let _ = unsafe { + MessageBoxW( + None, + &HSTRING::from(content), + windows::core::w!("Error: Zed update failed."), + MB_ICONERROR | MB_SYSTEMMODAL, + ) + }; + } +} diff --git a/crates/auto_update_helper/src/dialog.rs b/crates/auto_update_helper/src/dialog.rs new file mode 100644 index 0000000000..010ebb4875 --- /dev/null +++ b/crates/auto_update_helper/src/dialog.rs @@ -0,0 +1,236 @@ +use std::{cell::RefCell, sync::mpsc::Receiver}; + +use anyhow::{Context as _, Result}; +use windows::{ + Win32::{ + Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::Gdi::{ + BeginPaint, CLEARTYPE_QUALITY, CLIP_DEFAULT_PRECIS, CreateFontW, DEFAULT_CHARSET, + DeleteObject, EndPaint, FW_NORMAL, LOGFONTW, OUT_TT_ONLY_PRECIS, PAINTSTRUCT, + ReleaseDC, SelectObject, TextOutW, + }, + System::LibraryLoader::GetModuleHandleW, + UI::{ + Controls::{PBM_SETRANGE, PBM_SETSTEP, PBM_STEPIT, PROGRESS_CLASS}, + WindowsAndMessaging::{ + CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, CreateWindowExW, DefWindowProcW, + GWLP_USERDATA, GetDesktopWindow, GetWindowLongPtrW, GetWindowRect, HICON, + IMAGE_ICON, LR_DEFAULTSIZE, LR_SHARED, LoadImageW, PostQuitMessage, RegisterClassW, + SPI_GETICONTITLELOGFONT, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SendMessageW, + SetWindowLongPtrW, SystemParametersInfoW, WINDOW_EX_STYLE, WM_CLOSE, WM_CREATE, + WM_DESTROY, WM_NCCREATE, WM_PAINT, WNDCLASSW, WS_CAPTION, WS_CHILD, WS_EX_TOPMOST, + WS_POPUP, WS_VISIBLE, + }, + }, + }, + core::HSTRING, +}; + +use crate::{ + updater::JOBS, + windows_impl::{WM_JOB_UPDATED, WM_TERMINATE, show_error}, +}; + +#[repr(C)] +#[derive(Debug)] +struct DialogInfo { + rx: Receiver>, + progress_bar: isize, +} + +pub(crate) fn create_dialog_window(receiver: Receiver>) -> Result { + unsafe { + let class_name = windows::core::w!("Zed-Auto-Updater-Dialog-Class"); + let module = GetModuleHandleW(None).context("unable to get module handle")?; + let handle = LoadImageW( + Some(module.into()), + windows::core::PCWSTR(1 as _), + IMAGE_ICON, + 0, + 0, + LR_DEFAULTSIZE | LR_SHARED, + ) + .context("unable to load icon file")?; + let wc = WNDCLASSW { + lpfnWndProc: Some(wnd_proc), + lpszClassName: class_name, + style: CS_HREDRAW | CS_VREDRAW, + hIcon: HICON(handle.0), + ..Default::default() + }; + RegisterClassW(&wc); + let mut rect = RECT::default(); + GetWindowRect(GetDesktopWindow(), &mut rect) + .context("unable to get desktop window rect")?; + let width = 400; + let height = 150; + let info = Box::new(RefCell::new(DialogInfo { + rx: receiver, + progress_bar: 0, + })); + + let hwnd = CreateWindowExW( + WS_EX_TOPMOST, + class_name, + windows::core::w!("Zed Editor"), + WS_VISIBLE | WS_POPUP | WS_CAPTION, + rect.right / 2 - width / 2, + rect.bottom / 2 - height / 2, + width, + height, + None, + None, + None, + Some(Box::into_raw(info) as _), + ) + .context("unable to create dialog window")?; + Ok(hwnd) + } +} + +macro_rules! return_if_failed { + ($e:expr) => { + match $e { + Ok(v) => v, + Err(e) => { + return LRESULT(e.code().0 as _); + } + } + }; +} + +macro_rules! make_lparam { + ($l:expr, $h:expr) => { + LPARAM(($l as u32 | ($h as u32) << 16) as isize) + }; +} + +unsafe extern "system" fn wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_NCCREATE => unsafe { + let create_struct = lparam.0 as *const CREATESTRUCTW; + let info = (*create_struct).lpCreateParams as *mut RefCell; + let info = Box::from_raw(info); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, Box::into_raw(info) as _); + DefWindowProcW(hwnd, msg, wparam, lparam) + }, + WM_CREATE => unsafe { + // Create progress bar + let mut rect = RECT::default(); + return_if_failed!(GetWindowRect(hwnd, &mut rect)); + let progress_bar = return_if_failed!(CreateWindowExW( + WINDOW_EX_STYLE(0), + PROGRESS_CLASS, + None, + WS_CHILD | WS_VISIBLE, + 20, + 50, + 340, + 35, + Some(hwnd), + None, + None, + None, + )); + SendMessageW( + progress_bar, + PBM_SETRANGE, + None, + Some(make_lparam!(0, JOBS.len() * 10)), + ); + SendMessageW(progress_bar, PBM_SETSTEP, Some(WPARAM(10)), None); + with_dialog_data(hwnd, |data| { + data.borrow_mut().progress_bar = progress_bar.0 as isize + }); + LRESULT(0) + }, + WM_PAINT => unsafe { + let mut ps = PAINTSTRUCT::default(); + let hdc = BeginPaint(hwnd, &mut ps); + + let font_name = get_system_ui_font_name(); + let font = CreateFontW( + 24, + 0, + 0, + 0, + FW_NORMAL.0 as _, + 0, + 0, + 0, + DEFAULT_CHARSET, + OUT_TT_ONLY_PRECIS, + CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, + 0, + &HSTRING::from(font_name), + ); + let temp = SelectObject(hdc, font.into()); + let string = HSTRING::from("Zed Editor is updating..."); + return_if_failed!(TextOutW(hdc, 20, 15, &string).ok()); + return_if_failed!(DeleteObject(temp).ok()); + + return_if_failed!(EndPaint(hwnd, &ps).ok()); + ReleaseDC(Some(hwnd), hdc); + + LRESULT(0) + }, + WM_JOB_UPDATED => with_dialog_data(hwnd, |data| { + let progress_bar = data.borrow().progress_bar; + unsafe { SendMessageW(HWND(progress_bar as _), PBM_STEPIT, None, None) } + }), + WM_TERMINATE => { + with_dialog_data(hwnd, |data| { + if let Ok(result) = data.borrow_mut().rx.recv() { + if let Err(e) = result { + log::error!("Failed to update Zed: {:?}", e); + show_error(format!("Error: {:?}", e)); + } + } + }); + unsafe { PostQuitMessage(0) }; + LRESULT(0) + } + WM_CLOSE => LRESULT(0), // Prevent user occasionally closing the window + WM_DESTROY => { + unsafe { PostQuitMessage(0) }; + LRESULT(0) + } + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} + +fn with_dialog_data(hwnd: HWND, f: F) -> T +where + F: FnOnce(&RefCell) -> T, +{ + let raw = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut RefCell }; + let data = unsafe { Box::from_raw(raw) }; + let result = f(data.as_ref()); + unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, Box::into_raw(data) as _) }; + result +} + +fn get_system_ui_font_name() -> String { + unsafe { + let mut info: LOGFONTW = std::mem::zeroed(); + if SystemParametersInfoW( + SPI_GETICONTITLELOGFONT, + std::mem::size_of::() as u32, + Some(&mut info as *mut _ as _), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ) + .is_ok() + { + let font_name = String::from_utf16_lossy(&info.lfFaceName); + font_name.trim_matches(char::from(0)).to_owned() + } else { + "MS Shell Dlg".to_owned() + } + } +} diff --git a/crates/auto_update_helper/src/updater.rs b/crates/auto_update_helper/src/updater.rs new file mode 100644 index 0000000000..1c3fc10655 --- /dev/null +++ b/crates/auto_update_helper/src/updater.rs @@ -0,0 +1,171 @@ +use std::{ + os::windows::process::CommandExt, + path::Path, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use windows::Win32::{ + Foundation::{HWND, LPARAM, WPARAM}, + System::Threading::CREATE_NEW_PROCESS_GROUP, + UI::WindowsAndMessaging::PostMessageW, +}; + +use crate::windows_impl::WM_JOB_UPDATED; + +type Job = fn(&Path) -> Result<()>; + +#[cfg(not(test))] +pub(crate) const JOBS: [Job; 6] = [ + // Delete old files + |app_dir| { + let zed_executable = app_dir.join("Zed.exe"); + log::info!("Removing old file: {}", zed_executable.display()); + std::fs::remove_file(&zed_executable).context(format!( + "Failed to remove old file {}", + zed_executable.display() + )) + }, + |app_dir| { + let zed_cli = app_dir.join("bin\\zed.exe"); + log::info!("Removing old file: {}", zed_cli.display()); + std::fs::remove_file(&zed_cli) + .context(format!("Failed to remove old file {}", zed_cli.display())) + }, + // Copy new files + |app_dir| { + let zed_executable_source = app_dir.join("install\\Zed.exe"); + let zed_executable_dest = app_dir.join("Zed.exe"); + log::info!( + "Copying new file {} to {}", + zed_executable_source.display(), + zed_executable_dest.display() + ); + std::fs::copy(&zed_executable_source, &zed_executable_dest) + .map(|_| ()) + .context(format!( + "Failed to copy new file {} to {}", + zed_executable_source.display(), + zed_executable_dest.display() + )) + }, + |app_dir| { + let zed_cli_source = app_dir.join("install\\bin\\zed.exe"); + let zed_cli_dest = app_dir.join("bin\\zed.exe"); + log::info!( + "Copying new file {} to {}", + zed_cli_source.display(), + zed_cli_dest.display() + ); + std::fs::copy(&zed_cli_source, &zed_cli_dest) + .map(|_| ()) + .context(format!( + "Failed to copy new file {} to {}", + zed_cli_source.display(), + zed_cli_dest.display() + )) + }, + // Clean up installer folder and updates folder + |app_dir| { + let updates_folder = app_dir.join("updates"); + log::info!("Cleaning up: {}", updates_folder.display()); + std::fs::remove_dir_all(&updates_folder).context(format!( + "Failed to remove updates folder {}", + updates_folder.display() + )) + }, + |app_dir| { + let installer_folder = app_dir.join("install"); + log::info!("Cleaning up: {}", installer_folder.display()); + std::fs::remove_dir_all(&installer_folder).context(format!( + "Failed to remove installer folder {}", + installer_folder.display() + )) + }, +]; + +#[cfg(test)] +pub(crate) const JOBS: [Job; 2] = [ + |_| { + std::thread::sleep(Duration::from_millis(1000)); + if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") { + match config.as_str() { + "err" => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Simulated error", + )) + .context("Anyhow!"), + _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config), + } + } else { + Ok(()) + } + }, + |_| { + std::thread::sleep(Duration::from_millis(1000)); + if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") { + match config.as_str() { + "err" => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Simulated error", + )) + .context("Anyhow!"), + _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config), + } + } else { + Ok(()) + } + }, +]; + +pub(crate) fn perform_update(app_dir: &Path, hwnd: Option) -> Result<()> { + let hwnd = hwnd.map(|ptr| HWND(ptr as _)); + + for job in JOBS.iter() { + let start = Instant::now(); + loop { + if start.elapsed().as_secs() > 2 { + return Err(anyhow::anyhow!("Timed out")); + } + match (*job)(app_dir) { + Ok(_) => { + unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? }; + break; + } + Err(err) => { + // Check if it's a "not found" error + let io_err = err.downcast_ref::().unwrap(); + if io_err.kind() == std::io::ErrorKind::NotFound { + log::warn!("File or folder not found."); + unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? }; + break; + } + + log::error!("Operation failed: {}", err); + std::thread::sleep(Duration::from_millis(50)); + } + } + } + } + let _ = std::process::Command::new(app_dir.join("Zed.exe")) + .creation_flags(CREATE_NEW_PROCESS_GROUP.0) + .spawn(); + log::info!("Update completed successfully"); + Ok(()) +} + +#[cfg(test)] +mod test { + use super::perform_update; + + #[test] + fn test_perform_update() { + let app_dir = std::path::Path::new("C:/"); + assert!(perform_update(app_dir, None).is_ok()); + + // Simulate a timeout + unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err") }; + let ret = perform_update(app_dir, None); + assert!(ret.is_err_and(|e| e.to_string().as_str() == "Timed out")); + } +} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 98a3ecbd26..21fff01cd5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -168,6 +168,16 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { } fn main() { + // Check if there is a pending installer + // If there is, run the installer and exit + // And we don't want to run the installer if we are not the first instance + #[cfg(target_os = "windows")] + let is_first_instance = crate::zed::windows_only_instance::is_first_instance(); + #[cfg(target_os = "windows")] + if is_first_instance && auto_update::check_pending_installation() { + return; + } + let args = Args::parse(); // Set custom data directory. @@ -236,27 +246,30 @@ fn main() { let (open_listener, mut open_rx) = OpenListener::new(); - let failed_single_instance_check = if *db::ZED_STATELESS - || *release_channel::RELEASE_CHANNEL == ReleaseChannel::Dev - { - false - } else { - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - { - crate::zed::listen_for_cli_connections(open_listener.clone()).is_err() - } + let failed_single_instance_check = + if *db::ZED_STATELESS || *release_channel::RELEASE_CHANNEL == ReleaseChannel::Dev { + false + } else { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + { + crate::zed::listen_for_cli_connections(open_listener.clone()).is_err() + } - #[cfg(target_os = "windows")] - { - !crate::zed::windows_only_instance::check_single_instance(open_listener.clone(), &args) - } + #[cfg(target_os = "windows")] + { + !crate::zed::windows_only_instance::handle_single_instance( + open_listener.clone(), + &args, + is_first_instance, + ) + } - #[cfg(target_os = "macos")] - { - use zed::mac_only_instance::*; - ensure_only_instance() != IsOnlyInstance::Yes - } - }; + #[cfg(target_os = "macos")] + { + use zed::mac_only_instance::*; + ensure_only_instance() != IsOnlyInstance::Yes + } + }; if failed_single_instance_check { println!("zed is already running"); return; diff --git a/crates/zed/src/zed/windows_only_instance.rs b/crates/zed/src/zed/windows_only_instance.rs index 92295b5006..972cad38fe 100644 --- a/crates/zed/src/zed/windows_only_instance.rs +++ b/crates/zed/src/zed/windows_only_instance.rs @@ -25,7 +25,7 @@ use windows::{ use crate::{Args, OpenListener}; -pub fn check_single_instance(opener: OpenListener, args: &Args) -> bool { +pub fn is_first_instance() -> bool { unsafe { CreateMutexW( None, @@ -34,9 +34,11 @@ pub fn check_single_instance(opener: OpenListener, args: &Args) -> bool { ) .expect("Unable to create instance mutex.") }; - let first_instance = unsafe { GetLastError() } != ERROR_ALREADY_EXISTS; + unsafe { GetLastError() != ERROR_ALREADY_EXISTS } +} - if first_instance { +pub fn handle_single_instance(opener: OpenListener, args: &Args, is_first_instance: bool) -> bool { + if is_first_instance { // We are the first instance, listen for messages sent from other instances std::thread::spawn(move || with_pipe(|url| opener.open_urls(vec![url]))); } else if !args.foreground { @@ -44,7 +46,7 @@ pub fn check_single_instance(opener: OpenListener, args: &Args) -> bool { send_args_to_instance(args).log_err(); } - first_instance + is_first_instance } fn with_pipe(f: impl Fn(String)) { diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index fac5fec310..c7a2529172 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -512,6 +512,8 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } winapi = { version = "0.3", default-features = false, features = ["cfg", "consoleapi", "errhandlingapi", "evntrace", "fileapi", "handleapi", "in6addr", "inaddr", "knownfolders", "minwinbase", "ntsecapi", "objbase", "processenv", "processthreadsapi", "shlobj", "std", "sysinfoapi", "winbase", "windef", "winerror", "winioctl"] } +windows-core = { version = "0.61" } +windows-numerics = { version = "0.2" } windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } windows-sys-c8eced492e86ede7 = { package = "windows-sys", version = "0.48", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Time", "Win32_UI_Shell"] } @@ -533,6 +535,8 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } winapi = { version = "0.3", default-features = false, features = ["cfg", "consoleapi", "errhandlingapi", "evntrace", "fileapi", "handleapi", "in6addr", "inaddr", "knownfolders", "minwinbase", "ntsecapi", "objbase", "processenv", "processthreadsapi", "shlobj", "std", "sysinfoapi", "winbase", "windef", "winerror", "winioctl"] } +windows-core = { version = "0.61" } +windows-numerics = { version = "0.2" } windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } windows-sys-c8eced492e86ede7 = { package = "windows-sys", version = "0.48", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Networking_WinSock", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Time", "Win32_UI_Shell"] } From 47b663a8dfa92af311e08f2ce3328528434b7b2d Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 14 Apr 2025 17:44:45 +0000 Subject: [PATCH 47/75] github: Add Staff-Only 'Other' Issue template (#28703) This is because [issues/new](https://github.com/zed-industries/zed/issues/new) now redirects to [issues/new/choose](https://github.com/zed-industries/zed/issues/new/choose) (good!) so you can no longer create issues skipping templates. Release Notes: - N/A --- .github/ISSUE_TEMPLATE/99_other.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/99_other.yml diff --git a/.github/ISSUE_TEMPLATE/99_other.yml b/.github/ISSUE_TEMPLATE/99_other.yml new file mode 100644 index 0000000000..9383a576b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/99_other.yml @@ -0,0 +1,19 @@ +name: Other [Staff Only] +description: Zed Staff Only +body: + - type: textarea + attributes: + label: Summary + value: | + + SUMMARY_SENTENCE_HERE + + ### Description + + IF YOU DO NOT WORK FOR ZED INDUSTRIES DO NOT CREATE ISSUES WITH THIS TEMPLATE. + THEY WILL BE AUTO-CLOSED AND MAY RESULT IN YOU BEING BANNED FROM THE ZED ISSUE TRACKER. + + FEATURE REQUESTS / SUPPORT REQUESTS SHOULD BE OPENED AS DISCUSSIONS: + https://github.com/zed-industries/zed/discussions/new/choose + validations: + required: true From ff41be30dc4b838d7e81996544d645a742ef1cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Marcos?= Date: Mon, 14 Apr 2025 15:09:28 -0300 Subject: [PATCH 48/75] Fix bugs with multicursor completions (#28586) Release Notes: - Fixed completions with multiple cursors leaving duplicated prefixes. - Fixed crash when accepting a completion in a multibuffer with multiple cursors. - Vim: improved `single-repeat` after accepting a completion, now pressing `.` to replay the completion will re-insert the completion text at the cursor position. --- crates/editor/src/editor.rs | 121 +++++------ crates/editor/src/editor_tests.rs | 274 +++++++++++++++++++++++- crates/editor/src/test.rs | 15 +- crates/multi_buffer/src/multi_buffer.rs | 7 +- 4 files changed, 341 insertions(+), 76 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d00d8e9b39..c80670bd45 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -4674,61 +4674,69 @@ impl Editor { snippet = None; new_text = completion.new_text.clone(); }; - let selections = self.selections.all::(cx); let replace_range = choose_completion_range(&completion, intent, &buffer_handle, cx); let buffer = buffer_handle.read(cx); - let old_text = buffer - .text_for_range(replace_range.clone()) - .collect::(); - - let newest_selection = self.selections.newest_anchor(); - if newest_selection.start.buffer_id != Some(buffer_handle.read(cx).remote_id()) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let replace_range_multibuffer = { + let excerpt = snapshot + .excerpt_containing(self.selections.newest_anchor().range()) + .unwrap(); + let multibuffer_anchor = snapshot + .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.start)) + .unwrap() + ..snapshot + .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.end)) + .unwrap(); + multibuffer_anchor.start.to_offset(&snapshot) + ..multibuffer_anchor.end.to_offset(&snapshot) + }; + let newest_anchor = self.selections.newest_anchor(); + if newest_anchor.head().buffer_id != Some(buffer.remote_id()) { return None; } - let lookbehind = newest_selection + let old_text = buffer + .text_for_range(replace_range.clone()) + .collect::(); + let lookbehind = newest_anchor .start .text_anchor .to_offset(buffer) .saturating_sub(replace_range.start); let lookahead = replace_range .end - .saturating_sub(newest_selection.end.text_anchor.to_offset(buffer)); - let mut common_prefix_len = 0; - for (a, b) in old_text.chars().zip(new_text.chars()) { - if a == b { - common_prefix_len += a.len_utf8(); - } else { - break; - } - } + .saturating_sub(newest_anchor.end.text_anchor.to_offset(buffer)); + let prefix = &old_text[..old_text.len() - lookahead]; + let suffix = &old_text[lookbehind..]; - let snapshot = self.buffer.read(cx).snapshot(cx); - let mut range_to_replace: Option> = None; - let mut ranges = Vec::new(); + let selections = self.selections.all::(cx); + let mut edits = Vec::new(); let mut linked_edits = HashMap::<_, Vec<_>>::default(); + for selection in &selections { - if snapshot.contains_str_at(selection.start.saturating_sub(lookbehind), &old_text) { - let start = selection.start.saturating_sub(lookbehind); - let end = selection.end + lookahead; - if selection.id == newest_selection.id { - range_to_replace = Some(start + common_prefix_len..end); - } - ranges.push(start + common_prefix_len..end); + let edit = if selection.id == newest_anchor.id { + (replace_range_multibuffer.clone(), new_text.as_str()) } else { - common_prefix_len = 0; - ranges.clear(); - ranges.extend(selections.iter().map(|s| { - if s.id == newest_selection.id { - range_to_replace = Some(replace_range.clone()); - replace_range.clone() - } else { - s.start..s.end + let mut range = selection.range(); + let mut text = new_text.as_str(); + + // if prefix is present, don't duplicate it + if snapshot.contains_str_at(range.start.saturating_sub(lookbehind), prefix) { + text = &new_text[lookbehind..]; + + // if suffix is also present, mimic the newest cursor and replace it + if selection.id != newest_anchor.id + && snapshot.contains_str_at(range.end, suffix) + { + range.end += lookahead; } - })); - break; - } + } + (range, text) + }; + + edits.push(edit); + if !self.linked_edit_ranges.is_empty() { let start_anchor = snapshot.anchor_before(selection.head()); let end_anchor = snapshot.anchor_after(selection.tail()); @@ -4736,45 +4744,30 @@ impl Editor { .linked_editing_ranges_for(start_anchor.text_anchor..end_anchor.text_anchor, cx) { for (buffer, edits) in ranges { - linked_edits.entry(buffer.clone()).or_default().extend( - edits - .into_iter() - .map(|range| (range, new_text[common_prefix_len..].to_owned())), - ); + linked_edits + .entry(buffer.clone()) + .or_default() + .extend(edits.into_iter().map(|range| (range, new_text.to_owned()))); } } } } - let text = &new_text[common_prefix_len..]; - let utf16_range_to_replace = range_to_replace.map(|range| { - let newest_selection = self.selections.newest::(cx).range(); - let selection_start_utf16 = newest_selection.start.0 as isize; - - range.start.to_offset_utf16(&snapshot).0 as isize - selection_start_utf16 - ..range.end.to_offset_utf16(&snapshot).0 as isize - selection_start_utf16 - }); cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace, - text: text.into(), + utf16_range_to_replace: None, + text: new_text.clone().into(), }); self.transact(window, cx, |this, window, cx| { if let Some(mut snippet) = snippet { - snippet.text = text.to_string(); - for tabstop in snippet - .tabstops - .iter_mut() - .flat_map(|tabstop| tabstop.ranges.iter_mut()) - { - tabstop.start -= common_prefix_len as isize; - tabstop.end -= common_prefix_len as isize; - } - + snippet.text = new_text.to_string(); + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); this.insert_snippet(&ranges, snippet, window, cx).log_err(); } else { this.buffer.update(cx, |buffer, cx| { - let edits = ranges.iter().map(|range| (range.clone(), text)); let auto_indent = if completion.insert_text_mode == Some(InsertTextMode::AS_IS) { None diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 78f9e67df6..51647b0226 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -9677,9 +9677,9 @@ async fn test_completion_mode(cx: &mut TestAppContext) { buffer_marked_text: "before after".into(), completion_text: "editor", expected_with_insert_mode: "before editorˇtor after".into(), - expected_with_replace_mode: "before ediˇtor after".into(), - expected_with_replace_subsequence_mode: "before ediˇtor after".into(), - expected_with_replace_suffix_mode: "before ediˇtor after".into(), + expected_with_replace_mode: "before editorˇ after".into(), + expected_with_replace_subsequence_mode: "before editorˇ after".into(), + expected_with_replace_suffix_mode: "before editorˇ after".into(), }, Run { run_description: "End of word matches completion text -- cursor at end", @@ -9727,9 +9727,9 @@ async fn test_completion_mode(cx: &mut TestAppContext) { buffer_marked_text: "[]".into(), completion_text: "element", expected_with_insert_mode: "[elementˇelement]".into(), - expected_with_replace_mode: "[elˇement]".into(), + expected_with_replace_mode: "[elementˇ]".into(), expected_with_replace_subsequence_mode: "[elementˇelement]".into(), - expected_with_replace_suffix_mode: "[elˇement]".into(), + expected_with_replace_suffix_mode: "[elementˇ]".into(), }, Run { run_description: "Ends with matching suffix", @@ -9923,6 +9923,270 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) apply_additional_edits.await.unwrap(); } +#[gpui::test] +async fn test_completion_replacing_suffix_in_multicursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + let initial_state = indoc! {" + 1. buf.to_offˇsuffix + 2. buf.to_offˇsuf + 3. buf.to_offˇfix + 4. buf.to_offˇ + 5. into_offˇensive + 6. ˇsuffix + 7. let ˇ // + 8. aaˇzz + 9. buf.to_off«zzzzzˇ»suffix + 10. buf.«ˇzzzzz»suffix + 11. to_off«ˇzzzzz» + + buf.to_offˇsuffix // newest cursor + "}; + let completion_marked_buffer = indoc! {" + 1. buf.to_offsuffix + 2. buf.to_offsuf + 3. buf.to_offfix + 4. buf.to_off + 5. into_offensive + 6. suffix + 7. let // + 8. aazz + 9. buf.to_offzzzzzsuffix + 10. buf.zzzzzsuffix + 11. to_offzzzzz + + buf. // newest cursor + "}; + let completion_text = "to_offset"; + let expected = indoc! {" + 1. buf.to_offsetˇ + 2. buf.to_offsetˇsuf + 3. buf.to_offsetˇfix + 4. buf.to_offsetˇ + 5. into_offsetˇensive + 6. to_offsetˇsuffix + 7. let to_offsetˇ // + 8. aato_offsetˇzz + 9. buf.to_offsetˇ + 10. buf.to_offsetˇsuffix + 11. to_offsetˇ + + buf.to_offsetˇ // newest cursor + "}; + + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + }); + + let counter = Arc::new(AtomicUsize::new(0)); + handle_completion_request_with_insert_and_replace( + &mut cx, + completion_marked_buffer, + vec![completion_text], + counter.clone(), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }); + cx.assert_editor_state(expected); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); +} + +// This used to crash +#[gpui::test] +async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_text = indoc! {" + fn main() { + 10.satu; + + // + // separate cursors so they open in different excerpts (manually reproducible) + // + + 10.satu20; + } + "}; + let multibuffer_text_with_selections = indoc! {" + fn main() { + 10.satuˇ; + + // + + // + + 10.satuˇ20; + } + "}; + let expected_multibuffer = indoc! {" + fn main() { + 10.saturating_sub()ˇ; + + // + + // + + 10.saturating_sub()ˇ; + } + "}; + + let first_excerpt_end = buffer_text.find("//").unwrap() + 3; + let second_excerpt_end = buffer_text.rfind("//").unwrap() - 4; + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": buffer_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: None, + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.push_excerpts( + buffer.clone(), + [ExcerptRange::new(0..first_excerpt_end)], + cx, + ); + multi_buffer.push_excerpts( + buffer.clone(), + [ExcerptRange::new(second_excerpt_end..buffer_text.len())], + cx, + ); + multi_buffer + }); + + let editor = workspace + .update(cx, |_, window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + }, + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }) + }) + .unwrap(); + + let pane = workspace + .update(cx, |workspace, _, _| workspace.active_pane().clone()) + .unwrap(); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(None, window, cx, |s| { + s.select_ranges([ + Point::new(1, 11)..Point::new(1, 11), + Point::new(7, 11)..Point::new(7, 11), + ]) + }); + + assert_text_with_selections(editor, multibuffer_text_with_selections, cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + }); + + fake_server + .set_request_handler::(move |_, _| async move { + let completion_item = lsp::CompletionItem { + label: "saturating_sub()".into(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "saturating_sub()".to_owned(), + insert: lsp::Range::new( + lsp::Position::new(7, 7), + lsp::Position::new(7, 11), + ), + replace: lsp::Range::new( + lsp::Position::new(7, 7), + lsp::Position::new(7, 13), + ), + }, + )), + ..lsp::CompletionItem::default() + }; + + Ok(Some(lsp::CompletionResponse::Array(vec![completion_item]))) + }) + .next() + .await + .unwrap(); + + cx.condition(&editor, |editor, _| editor.context_menu_visible()) + .await; + + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }) + .await + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, expected_multibuffer, cx); + }) +} + #[gpui::test] async fn test_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index e207b9988e..b197c56bbc 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -1,8 +1,7 @@ pub mod editor_lsp_test_context; pub mod editor_test_context; -use std::sync::LazyLock; - +pub use crate::rust_analyzer_ext::expand_macro_recursively; use crate::{ DisplayPoint, Editor, EditorMode, FoldPlaceholder, MultiBuffer, display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, @@ -11,11 +10,11 @@ use gpui::{ AppContext as _, Context, Entity, Font, FontFeatures, FontStyle, FontWeight, Pixels, Window, font, }; +use pretty_assertions::assert_eq; use project::Project; +use std::sync::LazyLock; use util::test::{marked_text_offsets, marked_text_ranges}; -pub use crate::rust_analyzer_ext::expand_macro_recursively; - #[cfg(test)] #[ctor::ctor] fn init_logger() { @@ -96,8 +95,12 @@ pub fn assert_text_with_selections( cx: &mut Context, ) { let (unmarked_text, text_ranges) = marked_text_ranges(marked_text, true); - assert_eq!(editor.text(cx), unmarked_text); - assert_eq!(editor.selections.ranges(cx), text_ranges); + assert_eq!(editor.text(cx), unmarked_text, "text doesn't match"); + assert_eq!( + editor.selections.ranges(cx), + text_ranges, + "selections don't match", + ); } // RA thinks this is dead code even though it is used in a whole lot of tests diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 18c17b3b02..994815910c 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -7694,7 +7694,12 @@ impl ToOffset for Point { impl ToOffset for usize { #[track_caller] fn to_offset<'a>(&self, snapshot: &MultiBufferSnapshot) -> usize { - assert!(*self <= snapshot.len(), "offset is out of range"); + assert!( + *self <= snapshot.len(), + "offset {} is greater than the snapshot.len() {}", + *self, + snapshot.len(), + ); *self } } From 6db29eb90adc2424c7d98cee3e02ec3e8d3779bc Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 14 Apr 2025 14:25:18 -0400 Subject: [PATCH 49/75] Remove debug assertions in git_store.rs (#28706) Closes #ISSUE Release Notes: - N/A --- crates/project/src/git_store.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index ddc610f755..024b347d19 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -3862,8 +3862,8 @@ impl Repository { fn spawn_local_git_worker( work_directory_abs_path: Arc, dot_git_abs_path: Arc, - repository_dir_abs_path: Arc, - common_dir_abs_path: Arc, + _repository_dir_abs_path: Arc, + _common_dir_abs_path: Arc, project_environment: WeakEntity, fs: Arc, cx: &mut Context, @@ -3889,9 +3889,6 @@ impl Repository { }) .await?; - debug_assert_eq!(backend.path().as_path(), repository_dir_abs_path.as_ref()); - debug_assert_eq!(backend.main_repository_path().as_path(), common_dir_abs_path.as_ref()); - if let Some(git_hosting_provider_registry) = cx.update(|cx| GitHostingProviderRegistry::try_global(cx))? { From 84aa480344d5bcbe731520c4650ea76097aad9ec Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Tue, 15 Apr 2025 00:45:59 +0530 Subject: [PATCH 50/75] Add support for OpenAI GPT-4.1 models (#28708) Release Notes: - Add support for OpenAI GPT-4.1 via Copilot Chat and OpenAI API --------- Co-authored-by: Danilo Leal Co-authored-by: Bennet Bo Fenner --- crates/copilot/src/copilot_chat.rs | 7 +++++++ .../language_model/src/model/cloud_model.rs | 3 +++ .../src/provider/copilot_chat.rs | 1 + crates/open_ai/src/open_ai.rs | 21 +++++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index e2c1499838..2df2ff8ca0 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -33,6 +33,8 @@ pub enum Model { Gpt4o, #[serde(alias = "gpt-4", rename = "gpt-4")] Gpt4, + #[serde(alias = "gpt-4.1", rename = "gpt-4.1")] + Gpt4_1, #[serde(alias = "gpt-3.5-turbo", rename = "gpt-3.5-turbo")] Gpt3_5Turbo, #[serde(alias = "o1", rename = "o1")] @@ -57,6 +59,7 @@ impl Model { match self { Self::Gpt4o | Self::Gpt4 + | Self::Gpt4_1 | Self::Gpt3_5Turbo | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet @@ -69,6 +72,7 @@ impl Model { match id { "gpt-4o" => Ok(Self::Gpt4o), "gpt-4" => Ok(Self::Gpt4), + "gpt-4.1" => Ok(Self::Gpt4_1), "gpt-3.5-turbo" => Ok(Self::Gpt3_5Turbo), "o1" => Ok(Self::O1), "o3-mini" => Ok(Self::O3Mini), @@ -84,6 +88,7 @@ impl Model { match self { Self::Gpt3_5Turbo => "gpt-3.5-turbo", Self::Gpt4 => "gpt-4", + Self::Gpt4_1 => "gpt-4.1", Self::Gpt4o => "gpt-4o", Self::O3Mini => "o3-mini", Self::O1 => "o1", @@ -98,6 +103,7 @@ impl Model { match self { Self::Gpt3_5Turbo => "GPT-3.5", Self::Gpt4 => "GPT-4", + Self::Gpt4_1 => "GPT-4.1", Self::Gpt4o => "GPT-4o", Self::O3Mini => "o3-mini", Self::O1 => "o1", @@ -112,6 +118,7 @@ impl Model { match self { Self::Gpt4o => 64_000, Self::Gpt4 => 32_768, + Self::Gpt4_1 => 1_047_576, Self::Gpt3_5Turbo => 12_288, Self::O3Mini => 64_000, Self::O1 => 20_000, diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index cc15ce3364..3c12cb1bd5 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -83,6 +83,9 @@ impl CloudModel { | open_ai::Model::FourTurbo | open_ai::Model::FourOmni | open_ai::Model::FourOmniMini + | open_ai::Model::FourPointOne + | open_ai::Model::FourPointOneMini + | open_ai::Model::FourPointOneNano | open_ai::Model::O1Mini | open_ai::Model::O1Preview | open_ai::Model::O1 diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 827ca3f190..81aac43f33 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -215,6 +215,7 @@ impl LanguageModel for CopilotChatLanguageModel { let model = match self.model { CopilotChatModel::Gpt4o => open_ai::Model::FourOmni, CopilotChatModel::Gpt4 => open_ai::Model::Four, + CopilotChatModel::Gpt4_1 => open_ai::Model::FourPointOne, CopilotChatModel::Gpt3_5Turbo => open_ai::Model::ThreePointFiveTurbo, CopilotChatModel::O1 | CopilotChatModel::O3Mini => open_ai::Model::Four, CopilotChatModel::Claude3_5Sonnet diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index b9aa2ce7f0..0aee8f4345 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -71,6 +71,12 @@ pub enum Model { FourOmni, #[serde(rename = "gpt-4o-mini", alias = "gpt-4o-mini")] FourOmniMini, + #[serde(rename = "gpt-4.1", alias = "gpt-4.1")] + FourPointOne, + #[serde(rename = "gpt-4.1-mini", alias = "gpt-4.1-mini")] + FourPointOneMini, + #[serde(rename = "gpt-4.1-nano", alias = "gpt-4.1-nano")] + FourPointOneNano, #[serde(rename = "o1", alias = "o1")] O1, #[serde(rename = "o1-preview", alias = "o1-preview")] @@ -99,6 +105,9 @@ impl Model { "gpt-4-turbo-preview" => Ok(Self::FourTurbo), "gpt-4o" => Ok(Self::FourOmni), "gpt-4o-mini" => Ok(Self::FourOmniMini), + "gpt-4.1" => Ok(Self::FourPointOne), + "gpt-4.1-mini" => Ok(Self::FourPointOneMini), + "gpt-4.1-nano" => Ok(Self::FourPointOneNano), "o1" => Ok(Self::O1), "o1-preview" => Ok(Self::O1Preview), "o1-mini" => Ok(Self::O1Mini), @@ -114,6 +123,9 @@ impl Model { Self::FourTurbo => "gpt-4-turbo", Self::FourOmni => "gpt-4o", Self::FourOmniMini => "gpt-4o-mini", + Self::FourPointOne => "gpt-4.1", + Self::FourPointOneMini => "gpt-4.1-mini", + Self::FourPointOneNano => "gpt-4.1-nano", Self::O1 => "o1", Self::O1Preview => "o1-preview", Self::O1Mini => "o1-mini", @@ -129,6 +141,9 @@ impl Model { Self::FourTurbo => "gpt-4-turbo", Self::FourOmni => "gpt-4o", Self::FourOmniMini => "gpt-4o-mini", + Self::FourPointOne => "gpt-4.1", + Self::FourPointOneMini => "gpt-4.1-mini", + Self::FourPointOneNano => "gpt-4.1-nano", Self::O1 => "o1", Self::O1Preview => "o1-preview", Self::O1Mini => "o1-mini", @@ -146,6 +161,9 @@ impl Model { Self::FourTurbo => 128_000, Self::FourOmni => 128_000, Self::FourOmniMini => 128_000, + Self::FourPointOne => 1_047_576, + Self::FourPointOneMini => 1_047_576, + Self::FourPointOneNano => 1_047_576, Self::O1 => 200_000, Self::O1Preview => 128_000, Self::O1Mini => 128_000, @@ -173,6 +191,9 @@ impl Model { | Self::FourTurbo | Self::FourOmni | Self::FourOmniMini + | Self::FourPointOne + | Self::FourPointOneMini + | Self::FourPointOneNano | Self::O1 | Self::O1Preview | Self::O1Mini => true, From 5b6efa4c02c66627f659d118a4e41a02fa5b8a03 Mon Sep 17 00:00:00 2001 From: Richard Hao Date: Tue, 15 Apr 2025 03:33:22 +0800 Subject: [PATCH 51/75] copilot_chat: Add Gemini 2.5 Pro support to Copilot Chat (#28660) --- crates/copilot/src/copilot_chat.rs | 10 ++++++++-- crates/language_models/src/provider/copilot_chat.rs | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 2df2ff8ca0..bc9308f7fe 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -52,6 +52,8 @@ pub enum Model { Claude3_7SonnetThinking, #[serde(alias = "gemini-2.0-flash", rename = "gemini-2.0-flash-001")] Gemini20Flash, + #[serde(alias = "gemini-2.5-pro", rename = "gemini-2.5-pro")] + Gemini25Pro, } impl Model { @@ -64,7 +66,7 @@ impl Model { | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => true, - Self::O3Mini | Self::O1 | Self::Gemini20Flash => false, + Self::O3Mini | Self::O1 | Self::Gemini20Flash | Self::Gemini25Pro => false, } } @@ -80,6 +82,7 @@ impl Model { "claude-3-7-sonnet" => Ok(Self::Claude3_7Sonnet), "claude-3.7-sonnet-thought" => Ok(Self::Claude3_7SonnetThinking), "gemini-2.0-flash-001" => Ok(Self::Gemini20Flash), + "gemini-2.5-pro" => Ok(Self::Gemini25Pro), _ => Err(anyhow!("Invalid model id: {}", id)), } } @@ -96,6 +99,7 @@ impl Model { Self::Claude3_7Sonnet => "claude-3-7-sonnet", Self::Claude3_7SonnetThinking => "claude-3.7-sonnet-thought", Self::Gemini20Flash => "gemini-2.0-flash-001", + Self::Gemini25Pro => "gemini-2.5-pro", } } @@ -111,6 +115,7 @@ impl Model { Self::Claude3_7Sonnet => "Claude 3.7 Sonnet", Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking", Self::Gemini20Flash => "Gemini 2.0 Flash", + Self::Gemini25Pro => "Gemini 2.5 Pro", } } @@ -125,7 +130,8 @@ impl Model { Self::Claude3_5Sonnet => 200_000, Self::Claude3_7Sonnet => 90_000, Self::Claude3_7SonnetThinking => 90_000, - Model::Gemini20Flash => 128_000, + Self::Gemini20Flash => 128_000, + Self::Gemini25Pro => 128_000, } } } diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 81aac43f33..cde252e04a 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -210,7 +210,9 @@ impl LanguageModel for CopilotChatLanguageModel { CopilotChatModel::Claude3_5Sonnet => count_anthropic_tokens(request, cx), CopilotChatModel::Claude3_7Sonnet => count_anthropic_tokens(request, cx), CopilotChatModel::Claude3_7SonnetThinking => count_anthropic_tokens(request, cx), - CopilotChatModel::Gemini20Flash => count_google_tokens(request, cx), + CopilotChatModel::Gemini20Flash | CopilotChatModel::Gemini25Pro => { + count_google_tokens(request, cx) + } _ => { let model = match self.model { CopilotChatModel::Gpt4o => open_ai::Model::FourOmni, @@ -221,7 +223,8 @@ impl LanguageModel for CopilotChatLanguageModel { CopilotChatModel::Claude3_5Sonnet | CopilotChatModel::Claude3_7Sonnet | CopilotChatModel::Claude3_7SonnetThinking - | CopilotChatModel::Gemini20Flash => { + | CopilotChatModel::Gemini20Flash + | CopilotChatModel::Gemini25Pro => { unreachable!() } }; From 6c93d107c2a1c4aff7c59b2589c906a911037bf4 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 14 Apr 2025 15:44:03 -0400 Subject: [PATCH 52/75] zlog: Ansi styling of zlog output to stdout (#28711) Co-Authored-By: Zed AI Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... Co-authored-by: Zed AI --- crates/zlog/src/sink.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/zlog/src/sink.rs b/crates/zlog/src/sink.rs index a04973f3e3..6a2a041b2a 100644 --- a/crates/zlog/src/sink.rs +++ b/crates/zlog/src/sink.rs @@ -10,6 +10,15 @@ use std::{ use crate::{SCOPE_STRING_SEP_CHAR, Scope}; +// ANSI color escape codes for log levels +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_BOLD: &str = "\x1b[1m"; +const ANSI_RED: &str = "\x1b[31m"; +const ANSI_YELLOW: &str = "\x1b[33m"; +const ANSI_GREEN: &str = "\x1b[32m"; +const ANSI_BLUE: &str = "\x1b[34m"; +const ANSI_MAGENTA: &str = "\x1b[35m"; + /// Whether stdout output is enabled. static mut ENABLED_SINKS_STDOUT: bool = false; @@ -72,15 +81,30 @@ const LEVEL_OUTPUT_STRINGS: [&str; 6] = [ "TRACE", // ]; +// Colors for different log levels +static LEVEL_ANSI_COLORS: [&str; 6] = [ + "", // nop + ANSI_RED, // Error: Red + ANSI_YELLOW, // Warn: Yellow + ANSI_GREEN, // Info: Green + ANSI_BLUE, // Debug: Blue + ANSI_MAGENTA, // Trace: Magenta +]; + pub fn submit(record: Record) { if unsafe { ENABLED_SINKS_STDOUT } { let mut stdout = std::io::stdout().lock(); _ = writeln!( &mut stdout, - "{} {} [{}] {}", + "{} {}{}{}{} {}[{}]{} {}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z"), + ANSI_BOLD, + LEVEL_ANSI_COLORS[record.level as usize], LEVEL_OUTPUT_STRINGS[record.level as usize], + ANSI_RESET, + ANSI_BOLD, ScopeFmt(record.scope), + ANSI_RESET, record.message ); } From 2603f36737201a3b7c4a4d42d9fd141106ace3b8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 14 Apr 2025 13:55:25 -0600 Subject: [PATCH 53/75] agent: Improve compatibility when using MCP servers with Gemini models (#28700) WIP Release Notes: - agent: Improve compatibility when using MCPs with Gemini models --- crates/agent/src/thread.rs | 21 +- crates/assistant_tool/src/assistant_tool.rs | 6 +- crates/assistant_tool/src/tool_schema.rs | 236 ++++++++++++++++++ crates/assistant_tools/src/assistant_tools.rs | 29 ++- crates/assistant_tools/src/batch_tool.rs | 2 +- .../assistant_tools/src/code_action_tool.rs | 7 +- .../assistant_tools/src/code_symbols_tool.rs | 2 +- crates/assistant_tools/src/copy_path_tool.rs | 2 +- .../src/create_directory_tool.rs | 2 +- .../assistant_tools/src/create_file_tool.rs | 2 +- .../assistant_tools/src/delete_path_tool.rs | 2 +- .../assistant_tools/src/diagnostics_tool.rs | 2 +- crates/assistant_tools/src/fetch_tool.rs | 2 +- .../src/find_replace_file_tool.rs | 2 +- .../src/list_directory_tool.rs | 2 +- crates/assistant_tools/src/move_path_tool.rs | 2 +- crates/assistant_tools/src/now_tool.rs | 2 +- crates/assistant_tools/src/open_tool.rs | 2 +- .../assistant_tools/src/path_search_tool.rs | 2 +- crates/assistant_tools/src/read_file_tool.rs | 2 +- .../assistant_tools/src/regex_search_tool.rs | 2 +- crates/assistant_tools/src/rename_tool.rs | 7 +- crates/assistant_tools/src/schema.rs | 56 +---- .../assistant_tools/src/symbol_info_tool.rs | 2 +- crates/assistant_tools/src/terminal_tool.rs | 2 +- crates/assistant_tools/src/thinking_tool.rs | 2 +- .../context_server/src/context_server_tool.rs | 10 +- 27 files changed, 307 insertions(+), 103 deletions(-) create mode 100644 crates/assistant_tool/src/tool_schema.rs diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 190f2ace0e..1cab03a46f 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -844,13 +844,20 @@ impl Thread { if model.supports_tools() { request.tools = { let mut tools = Vec::new(); - tools.extend(self.tools().enabled_tools(cx).into_iter().map(|tool| { - LanguageModelRequestTool { - name: tool.name(), - description: tool.description(), - input_schema: tool.input_schema(model.tool_input_format()), - } - })); + tools.extend( + self.tools() + .enabled_tools(cx) + .into_iter() + .filter_map(|tool| { + // Skip tools that cannot be supported + let input_schema = tool.input_schema(model.tool_input_format()).ok()?; + Some(LanguageModelRequestTool { + name: tool.name(), + description: tool.description(), + input_schema, + }) + }), + ); tools }; diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 59879b80d3..81ab61d970 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -1,5 +1,6 @@ mod action_log; mod tool_registry; +mod tool_schema; mod tool_working_set; use std::fmt; @@ -16,6 +17,7 @@ use project::Project; pub use crate::action_log::*; pub use crate::tool_registry::*; +pub use crate::tool_schema::*; pub use crate::tool_working_set::*; pub fn init(cx: &mut App) { @@ -51,8 +53,8 @@ pub trait Tool: 'static + Send + Sync { fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool; /// Returns the JSON schema that describes the tool's input. - fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> serde_json::Value { - serde_json::Value::Object(serde_json::Map::default()) + fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result { + Ok(serde_json::Value::Object(serde_json::Map::default())) } /// Returns markdown to be displayed in the UI for this tool. diff --git a/crates/assistant_tool/src/tool_schema.rs b/crates/assistant_tool/src/tool_schema.rs new file mode 100644 index 0000000000..225c1c22ef --- /dev/null +++ b/crates/assistant_tool/src/tool_schema.rs @@ -0,0 +1,236 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::LanguageModelToolSchemaFormat; + +/// Tries to adapt a JSON schema representation to be compatible with the specified format. +/// +/// If the json cannot be made compatible with the specified format, an error is returned. +pub fn adapt_schema_to_format( + json: &mut Value, + format: LanguageModelToolSchemaFormat, +) -> Result<()> { + match format { + LanguageModelToolSchemaFormat::JsonSchema => Ok(()), + LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json), + } +} + +/// Tries to adapt the json schema so that it is compatible with https://ai.google.dev/api/caching#Schema +fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { + if let Value::Object(obj) = json { + const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"]; + + for key in UNSUPPORTED_KEYS { + if obj.contains_key(key) { + return Err(anyhow::anyhow!( + "Schema cannot be made compatible because it contains \"{}\" ", + key + )); + } + } + + const KEYS_TO_REMOVE: [&str; 2] = ["format", "$schema"]; + for key in KEYS_TO_REMOVE { + obj.remove(key); + } + + if let Some(default) = obj.get("default") { + let is_null = default.is_null(); + // Default is not supported, so we need to remove it + obj.remove("default"); + if is_null { + obj.insert("nullable".to_string(), Value::Bool(true)); + } + } + + // If a type is not specified for an input parameter, add a default type + if obj.contains_key("description") + && !obj.contains_key("type") + && !(obj.contains_key("anyOf") + || obj.contains_key("oneOf") + || obj.contains_key("allOf")) + { + obj.insert("type".to_string(), Value::String("string".to_string())); + } + + // Handle oneOf -> anyOf conversion + if let Some(subschemas) = obj.get_mut("oneOf") { + if subschemas.is_array() { + let subschemas_clone = subschemas.clone(); + obj.remove("oneOf"); + obj.insert("anyOf".to_string(), subschemas_clone); + } + } + + // Recursively process all nested objects and arrays + for (_, value) in obj.iter_mut() { + if let Value::Object(_) | Value::Array(_) = value { + adapt_to_json_schema_subset(value)?; + } + } + } else if let Value::Array(arr) = json { + for item in arr.iter_mut() { + adapt_to_json_schema_subset(item)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_transform_default_null_to_nullable() { + let mut json = json!({ + "description": "A test field", + "type": "string", + "default": null + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "description": "A test field", + "type": "string", + "nullable": true + }) + ); + } + + #[test] + fn test_transform_adds_type_when_missing() { + let mut json = json!({ + "description": "A test field without type" + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "description": "A test field without type", + "type": "string" + }) + ); + } + + #[test] + fn test_transform_removes_format() { + let mut json = json!({ + "description": "A test field", + "type": "integer", + "format": "uint32" + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "description": "A test field", + "type": "integer" + }) + ); + } + + #[test] + fn test_transform_one_of_to_any_of() { + let mut json = json!({ + "description": "A test field", + "oneOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "description": "A test field", + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }) + ); + } + + #[test] + fn test_transform_nested_objects() { + let mut json = json!({ + "type": "object", + "properties": { + "nested": { + "oneOf": [ + { "type": "string" }, + { "type": "null" } + ], + "format": "email" + } + } + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "nested": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + } + }) + ); + } + + #[test] + fn test_transform_fails_if_unsupported_keys_exist() { + let mut json = json!({ + "type": "object", + "properties": { + "$ref": "#/definitions/User", + } + }); + + assert!(adapt_to_json_schema_subset(&mut json).is_err()); + + let mut json = json!({ + "type": "object", + "properties": { + "if": "...", + } + }); + + assert!(adapt_to_json_schema_subset(&mut json).is_err()); + + let mut json = json!({ + "type": "object", + "properties": { + "then": "...", + } + }); + + assert!(adapt_to_json_schema_subset(&mut json).is_err()); + + let mut json = json!({ + "type": "object", + "properties": { + "else": "...", + } + }); + + assert!(adapt_to_json_schema_subset(&mut json).is_err()); + } +} diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index bede5866ef..76e8b8670b 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -84,7 +84,7 @@ mod tests { use super::*; #[gpui::test] - fn test_tool_schema_compatibility(cx: &mut App) { + fn test_builtin_tool_schema_compatibility(cx: &mut App) { crate::init( Arc::new(http_client::HttpClientWithUrl::new( FakeHttpClient::with_200_response(), @@ -95,18 +95,23 @@ mod tests { ); for tool in ToolRegistry::global(cx).tools() { - let schema = - tool.input_schema(language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset); - assert!(schema.is_object()); - if schema.as_object().unwrap().contains_key("$schema") { - let error_message = format!( - "Tool schema for `{}` is not compatible with `language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset` (Gemini Models).\n\ - Are you using `schema::json_schema_for(format)` to generate the schema?", - tool.name() - ); + let actual_schema = tool + .input_schema(language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset) + .unwrap(); + let mut expected_schema = actual_schema.clone(); + assistant_tool::adapt_schema_to_format( + &mut expected_schema, + language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset, + ) + .unwrap(); - panic!("{}", error_message) - } + let error_message = format!( + "Tool schema for `{}` is not compatible with `language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset` (Gemini Models).\n\ + Are you using `schema::json_schema_for(format)` to generate the schema?", + tool.name(), + ); + + assert_eq!(actual_schema, expected_schema, "{}", error_message) } } } diff --git a/crates/assistant_tools/src/batch_tool.rs b/crates/assistant_tools/src/batch_tool.rs index 751d2f8272..7ba1056ad9 100644 --- a/crates/assistant_tools/src/batch_tool.rs +++ b/crates/assistant_tools/src/batch_tool.rs @@ -172,7 +172,7 @@ impl Tool for BatchTool { IconName::Cog } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/code_action_tool.rs b/crates/assistant_tools/src/code_action_tool.rs index 7ef223b1b3..da62b8014a 100644 --- a/crates/assistant_tools/src/code_action_tool.rs +++ b/crates/assistant_tools/src/code_action_tool.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ActionLog, Tool}; use gpui::{App, Entity, Task}; use language::{self, Anchor, Buffer, ToPointUtf16}; -use language_model::LanguageModelRequestMessage; +use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; use project::{self, LspAction, Project}; use regex::Regex; use schemars::JsonSchema; @@ -97,10 +97,7 @@ impl Tool for CodeActionTool { IconName::Wand } - fn input_schema( - &self, - format: language_model::LanguageModelToolSchemaFormat, - ) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/code_symbols_tool.rs b/crates/assistant_tools/src/code_symbols_tool.rs index 9f0219b281..25689bd61d 100644 --- a/crates/assistant_tools/src/code_symbols_tool.rs +++ b/crates/assistant_tools/src/code_symbols_tool.rs @@ -91,7 +91,7 @@ impl Tool for CodeSymbolsTool { IconName::Code } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index c5995d6ff9..7e164dfc4d 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -55,7 +55,7 @@ impl Tool for CopyPathTool { IconName::Clipboard } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index 2ca7b27303..960d2b963e 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/crates/assistant_tools/src/create_directory_tool.rs @@ -45,7 +45,7 @@ impl Tool for CreateDirectoryTool { IconName::Folder } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/create_file_tool.rs b/crates/assistant_tools/src/create_file_tool.rs index 26dd12b6c8..de111c3ac9 100644 --- a/crates/assistant_tools/src/create_file_tool.rs +++ b/crates/assistant_tools/src/create_file_tool.rs @@ -52,7 +52,7 @@ impl Tool for CreateFileTool { IconName::FileCreate } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index b831f14b42..515dbf88af 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -45,7 +45,7 @@ impl Tool for DeletePathTool { IconName::FileDelete } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 704e1e6d57..acc36ff96b 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -58,7 +58,7 @@ impl Tool for DiagnosticsTool { IconName::XCircle } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 90a4c0ca05..33889cc693 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -128,7 +128,7 @@ impl Tool for FetchTool { IconName::Globe } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/find_replace_file_tool.rs b/crates/assistant_tools/src/find_replace_file_tool.rs index b770288ae7..580f039a27 100644 --- a/crates/assistant_tools/src/find_replace_file_tool.rs +++ b/crates/assistant_tools/src/find_replace_file_tool.rs @@ -151,7 +151,7 @@ impl Tool for FindReplaceFileTool { IconName::Pencil } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index 2a0615fa54..4e581ba26d 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -56,7 +56,7 @@ impl Tool for ListDirectoryTool { IconName::Folder } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index b44023f122..338c5a2d4d 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/crates/assistant_tools/src/move_path_tool.rs @@ -54,7 +54,7 @@ impl Tool for MovePathTool { IconName::ArrowRightLeft } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index 45279daa3a..d66fd0a5c1 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -45,7 +45,7 @@ impl Tool for NowTool { IconName::Info } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs index a64a50edbf..de49f8914b 100644 --- a/crates/assistant_tools/src/open_tool.rs +++ b/crates/assistant_tools/src/open_tool.rs @@ -35,7 +35,7 @@ impl Tool for OpenTool { IconName::ArrowUpRight } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/path_search_tool.rs b/crates/assistant_tools/src/path_search_tool.rs index 604084395c..919d75f8f4 100644 --- a/crates/assistant_tools/src/path_search_tool.rs +++ b/crates/assistant_tools/src/path_search_tool.rs @@ -53,7 +53,7 @@ impl Tool for PathSearchTool { IconName::SearchCode } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index eb4f5c7a77..6e4f23090b 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -63,7 +63,7 @@ impl Tool for ReadFileTool { IconName::FileSearch } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/regex_search_tool.rs b/crates/assistant_tools/src/regex_search_tool.rs index 120279e4b6..0eef07f776 100644 --- a/crates/assistant_tools/src/regex_search_tool.rs +++ b/crates/assistant_tools/src/regex_search_tool.rs @@ -60,7 +60,7 @@ impl Tool for RegexSearchTool { IconName::Regex } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/rename_tool.rs b/crates/assistant_tools/src/rename_tool.rs index 51e087f1b5..0562fb35aa 100644 --- a/crates/assistant_tools/src/rename_tool.rs +++ b/crates/assistant_tools/src/rename_tool.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ActionLog, Tool}; use gpui::{App, Entity, Task}; use language::{self, Buffer, ToPointUtf16}; -use language_model::LanguageModelRequestMessage; +use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -68,10 +68,7 @@ impl Tool for RenameTool { IconName::Pencil } - fn input_schema( - &self, - format: language_model::LanguageModelToolSchemaFormat, - ) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/schema.rs b/crates/assistant_tools/src/schema.rs index 10ae594ecd..4a71d47d2c 100644 --- a/crates/assistant_tools/src/schema.rs +++ b/crates/assistant_tools/src/schema.rs @@ -5,23 +5,20 @@ use schemars::{ schema::{RootSchema, Schema, SchemaObject}, }; -pub fn json_schema_for(format: LanguageModelToolSchemaFormat) -> serde_json::Value { +pub fn json_schema_for( + format: LanguageModelToolSchemaFormat, +) -> Result { let schema = root_schema_for::(format); - schema_to_json(&schema, format).expect("Failed to convert tool calling schema to JSON") + schema_to_json(&schema, format) } -pub fn schema_to_json( +fn schema_to_json( schema: &RootSchema, format: LanguageModelToolSchemaFormat, ) -> Result { let mut value = serde_json::to_value(schema)?; - match format { - LanguageModelToolSchemaFormat::JsonSchema => Ok(value), - LanguageModelToolSchemaFormat::JsonSchemaSubset => { - transform_fields_to_json_schema_subset(&mut value); - Ok(value) - } - } + assistant_tool::adapt_schema_to_format(&mut value, format)?; + Ok(value) } fn root_schema_for(format: LanguageModelToolSchemaFormat) -> RootSchema { @@ -79,42 +76,3 @@ impl schemars::visit::Visitor for TransformToJsonSchemaSubsetVisitor { schemars::visit::visit_schema_object(self, schema) } } - -fn transform_fields_to_json_schema_subset(json: &mut serde_json::Value) { - if let serde_json::Value::Object(obj) = json { - if let Some(default) = obj.get("default") { - let is_null = default.is_null(); - //Default is not supported, so we need to remove it. - obj.remove("default"); - if is_null { - obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - - // If a type is not specified for an input parameter we need to add it. - if obj.contains_key("description") - && !obj.contains_key("type") - && !(obj.contains_key("anyOf") - || obj.contains_key("oneOf") - || obj.contains_key("allOf")) - { - obj.insert( - "type".to_string(), - serde_json::Value::String("string".to_string()), - ); - } - - //Format field is only partially supported (e.g. not uint compatibility) - obj.remove("format"); - - for (_, value) in obj.iter_mut() { - if let serde_json::Value::Object(_) | serde_json::Value::Array(_) = value { - transform_fields_to_json_schema_subset(value); - } - } - } else if let serde_json::Value::Array(arr) = json { - for item in arr.iter_mut() { - transform_fields_to_json_schema_subset(item); - } - } -} diff --git a/crates/assistant_tools/src/symbol_info_tool.rs b/crates/assistant_tools/src/symbol_info_tool.rs index 6d86679b38..98000c9b54 100644 --- a/crates/assistant_tools/src/symbol_info_tool.rs +++ b/crates/assistant_tools/src/symbol_info_tool.rs @@ -84,7 +84,7 @@ impl Tool for SymbolInfoTool { IconName::Code } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 22f99727ee..bb67b312a4 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -44,7 +44,7 @@ impl Tool for TerminalTool { IconName::Terminal } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 07f40daaeb..e94f21692f 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -36,7 +36,7 @@ impl Tool for ThinkingTool { IconName::LightBulb } - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } diff --git a/crates/context_server/src/context_server_tool.rs b/crates/context_server/src/context_server_tool.rs index ac952e4bd2..a3afc3ef7b 100644 --- a/crates/context_server/src/context_server_tool.rs +++ b/crates/context_server/src/context_server_tool.rs @@ -53,16 +53,18 @@ impl Tool for ContextServerTool { true } - fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> serde_json::Value { - match &self.tool.input_schema { + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { + let mut schema = self.tool.input_schema.clone(); + assistant_tool::adapt_schema_to_format(&mut schema, format)?; + Ok(match schema { serde_json::Value::Null => { serde_json::json!({ "type": "object", "properties": [] }) } serde_json::Value::Object(map) if map.is_empty() => { serde_json::json!({ "type": "object", "properties": [] }) } - _ => self.tool.input_schema.clone(), - } + _ => schema, + }) } fn ui_text(&self, _input: &serde_json::Value) -> String { From 6b80eb556c3fcbcb52fe669be00a086cf9c9ff26 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 14 Apr 2025 14:18:47 -0600 Subject: [PATCH 54/75] Add judge to new eval + provide LSP diagnostics (#28713) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra Co-authored-by: agus --- Cargo.lock | 11 + crates/agent/src/thread.rs | 16 +- crates/eval/.gitignore | 3 + crates/eval/Cargo.toml | 13 +- .../find_and_replace_diff_card/base.toml | 3 +- .../find_and_replace_diff_card/criteria.md | 2 + .../find_and_replace_diff_card/rubric.md | 0 crates/eval/src/eval.rs | 247 ++++++- crates/eval/src/example.rs | 600 ++++++++++++++++-- crates/eval/src/judge_prompt.hbs | 25 + crates/language_model/src/language_model.rs | 2 +- 11 files changed, 838 insertions(+), 84 deletions(-) create mode 100644 crates/eval/.gitignore create mode 100644 crates/eval/examples/find_and_replace_diff_card/criteria.md delete mode 100644 crates/eval/examples/find_and_replace_diff_card/rubric.md create mode 100644 crates/eval/src/judge_prompt.hbs diff --git a/Cargo.lock b/Cargo.lock index 911ea64417..73761469b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4878,25 +4878,36 @@ dependencies = [ "assistant_settings", "assistant_tool", "assistant_tools", + "async-watch", + "chrono", + "clap", "client", "context_server", "dap", "env_logger 0.11.8", + "extension", "fs", "futures 0.3.31", "gpui", "gpui_tokio", + "handlebars 4.5.0", "language", + "language_extension", "language_model", "language_models", + "languages", "node_runtime", + "paths", "project", "prompt_store", "release_channel", "reqwest_client", "serde", "settings", + "shellexpand 2.1.2", "toml 0.8.20", + "unindent", + "util", "workspace-hack", ] diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 1cab03a46f..fe4844bd86 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -827,7 +827,7 @@ impl Thread { }) .collect(), initial_project_snapshot, - cumulative_token_usage: this.cumulative_token_usage.clone(), + cumulative_token_usage: this.cumulative_token_usage, detailed_summary_state: this.detailed_summary_state.clone(), exceeded_window_error: this.exceeded_window_error.clone(), }) @@ -1016,7 +1016,7 @@ impl Thread { let task = cx.spawn(async move |thread, cx| { let stream = model.stream_completion(request, &cx); let initial_token_usage = - thread.read_with(cx, |thread, _cx| thread.cumulative_token_usage.clone()); + thread.read_with(cx, |thread, _cx| thread.cumulative_token_usage); let stream_completion = async { let mut events = stream.await?; let mut stop_reason = StopReason::EndTurn; @@ -1038,9 +1038,9 @@ impl Thread { stop_reason = reason; } LanguageModelCompletionEvent::UsageUpdate(token_usage) => { - thread.cumulative_token_usage = - thread.cumulative_token_usage.clone() + token_usage.clone() - - current_token_usage.clone(); + thread.cumulative_token_usage = thread.cumulative_token_usage + + token_usage + - current_token_usage; current_token_usage = token_usage; } LanguageModelCompletionEvent::Text(chunk) => { @@ -1183,7 +1183,7 @@ impl Thread { thread.auto_capture_telemetry(cx); if let Ok(initial_usage) = initial_token_usage { - let usage = thread.cumulative_token_usage.clone() - initial_usage; + let usage = thread.cumulative_token_usage - initial_usage; telemetry::event!( "Assistant Thread Completion", @@ -1862,6 +1862,10 @@ impl Thread { .detach(); } + pub fn cumulative_token_usage(&self) -> TokenUsage { + self.cumulative_token_usage + } + pub fn total_token_usage(&self, cx: &App) -> TotalTokenUsage { let model_registry = LanguageModelRegistry::read_global(cx); let Some(model) = model_registry.default_model() else { diff --git a/crates/eval/.gitignore b/crates/eval/.gitignore new file mode 100644 index 0000000000..89fb02c122 --- /dev/null +++ b/crates/eval/.gitignore @@ -0,0 +1,3 @@ +repos/ +worktrees/ +runs/ diff --git a/crates/eval/Cargo.toml b/crates/eval/Cargo.toml index 0249c24dcf..6828de36fc 100644 --- a/crates/eval/Cargo.toml +++ b/crates/eval/Cargo.toml @@ -7,28 +7,39 @@ edition.workspace = true [dependencies] agent.workspace = true anyhow.workspace = true +async-watch.workspace = true +assistant_settings.workspace = true assistant_tool.workspace = true assistant_tools.workspace = true -assistant_settings.workspace = true +chrono.workspace = true +clap.workspace = true client.workspace = true context_server.workspace = true dap.workspace = true env_logger.workspace = true +extension.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio.workspace = true +handlebars.workspace = true language.workspace = true +language_extension.workspace = true language_model.workspace = true language_models.workspace = true +languages.workspace = true node_runtime.workspace = true +paths.workspace = true project.workspace = true prompt_store.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true settings.workspace = true +shellexpand.workspace = true toml.workspace = true +unindent.workspace = true +util.workspace = true workspace-hack.workspace = true [[bin]] diff --git a/crates/eval/examples/find_and_replace_diff_card/base.toml b/crates/eval/examples/find_and_replace_diff_card/base.toml index 2b14a64530..c88298997d 100644 --- a/crates/eval/examples/find_and_replace_diff_card/base.toml +++ b/crates/eval/examples/find_and_replace_diff_card/base.toml @@ -1,2 +1,3 @@ -path = "../zed_worktree" +url = "https://github.com/zed-industries/zed.git" revision = "38fcadf9481d018543c65f36ac3bafeba190179b" +language_extension = "rs" diff --git a/crates/eval/examples/find_and_replace_diff_card/criteria.md b/crates/eval/examples/find_and_replace_diff_card/criteria.md new file mode 100644 index 0000000000..393056f134 --- /dev/null +++ b/crates/eval/examples/find_and_replace_diff_card/criteria.md @@ -0,0 +1,2 @@ +1. The changes must replace the previous output returned by `FindReplaceFileTool` with the new `ToolResult` struct. The struct should contain an `output` field that is the same as the string we were returning before, and a new `card` field that contains a view for the card +2. The card should be a view that displays a diff. Each line in the diff should be colored according to whether it was added, removed or unchanged. diff --git a/crates/eval/examples/find_and_replace_diff_card/rubric.md b/crates/eval/examples/find_and_replace_diff_card/rubric.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 88cca63852..cfdb00b655 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -1,32 +1,75 @@ mod example; use assistant_settings::AssistantSettings; -use client::{Client, UserStore}; +use client::{Client, ProxySettings, UserStore}; pub(crate) use example::*; use ::fs::RealFs; -use anyhow::anyhow; -use gpui::{App, AppContext, Application, Entity, SemanticVersion, Task}; +use anyhow::{Result, anyhow}; +use clap::Parser; +use extension::ExtensionHostProxy; +use futures::future; +use gpui::http_client::{Uri, read_proxy_from_env}; +use gpui::{App, AppContext, Application, AsyncApp, Entity, SemanticVersion, Task}; +use gpui_tokio::Tokio; use language::LanguageRegistry; use language_model::{ AuthenticateError, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, }; -use node_runtime::NodeRuntime; +use node_runtime::{NodeBinaryOptions, NodeRuntime}; use project::Project; +use project::project_settings::ProjectSettings; use prompt_store::PromptBuilder; +use release_channel::AppVersion; use reqwest_client::ReqwestClient; use settings::{Settings, SettingsStore}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use util::ResultExt as _; + +pub const RUNS_DIR: &str = "./crates/eval/runs"; + +#[derive(Parser, Debug)] +#[command(name = "eval", disable_version_flag = true)] +struct Args { + /// Runs all examples that contain these substrings. If unspecified, all examples are run. + #[arg(value_name = "EXAMPLE_SUBSTRING")] + examples: Vec, + /// Model to use (default: "claude-3-7-sonnet-latest") + #[arg(long, default_value = "claude-3-7-sonnet-latest")] + model: String, +} fn main() { env_logger::init(); + + let args = Args::parse(); + let all_available_examples = list_all_examples().unwrap(); + let example_paths = all_available_examples + .iter() + .filter_map(|example_path| { + let name = example_path.file_name()?.to_string_lossy(); + if args.examples.is_empty() + || args + .examples + .iter() + .any(|name_substring| name.contains(name_substring)) + { + Some(example_path.clone()) + } else { + None + } + }) + .collect::>(); + let http_client = Arc::new(ReqwestClient::new()); let app = Application::headless().with_http_client(http_client.clone()); app.run(move |cx| { let app_state = init(cx); - let model = find_model("claude-3-7-sonnet-thinking-latest", cx).unwrap(); + let model = find_model("claude-3-7-sonnet-latest", cx).unwrap(); LanguageModelRegistry::global(cx).update(cx, |registry, cx| { registry.set_default_model(Some(model.clone()), cx); @@ -39,17 +82,142 @@ fn main() { cx.spawn(async move |cx| { authenticate.await.unwrap(); - let example = - Example::load_from_directory("./crates/eval/examples/find_and_replace_diff_card")?; - example.setup()?; - cx.update(|cx| example.run(model, app_state, cx))?.await?; + std::fs::create_dir_all(REPOS_DIR)?; + std::fs::create_dir_all(WORKTREES_DIR)?; - anyhow::Ok(()) + let run_dir = Path::new(RUNS_DIR).join(format!( + "{}", + chrono::Local::now().format("%Y-%m-%d_%H-%M-%S") + )); + std::fs::create_dir_all(&run_dir)?; + + let mut examples = Vec::new(); + for example_path in example_paths { + let example = Example::load_from_directory(&example_path, &run_dir)?; + examples.push((example_path, example)); + } + let mut repo_urls = HashSet::new(); + + let mut clone_tasks = Vec::new(); + + for (_, example) in examples.iter() { + let repo_url = example.base.url.clone(); + if repo_urls.insert(repo_url.clone()) { + let repo_path = repo_path_for_url(&repo_url); + + if !repo_path.join(".git").is_dir() { + println!("Cloning: {}", repo_url); + + let git_task = cx.spawn(async move |_cx| { + std::fs::create_dir_all(&repo_path)?; + run_git(&repo_path, &["init"]).await?; + run_git(&repo_path, &["remote", "add", "origin", &repo_url]).await + }); + + clone_tasks.push(git_task); + } else { + println!("Already cloned: {}", repo_url); + + let actual_origin = + run_git(&repo_path, &["remote", "get-url", "origin"]).await?; + if actual_origin != repo_url { + return Err(anyhow!( + "remote origin {} does not match expected origin {}", + actual_origin, + repo_url, + )); + } + } + } + } + + future::join_all(clone_tasks).await; + + let tasks = examples + .into_iter() + .map(|(example_path, example)| { + let app_state = app_state.clone(); + let model = model.clone(); + cx.spawn(async move |cx| { + ( + example_path, + run_example(example, model, app_state, cx).await, + ) + }) + }) + .collect::>(); + + let results: Vec<(PathBuf, Result)> = future::join_all(tasks).await; + + println!("\n\n"); + println!("========================================"); + println!(" EVAL RESULTS "); + println!("========================================"); + println!(""); + + let mut judge_scores = Vec::new(); + + for (example_path, result) in results { + let example_name = example_path.file_name().unwrap().to_string_lossy(); + match result { + Err(err) => { + println!("💥 {:<30}: {:?}", example_name, err); + } + Ok(judge_output) => { + const SCORES: [&str; 6] = ["💀", "😭", "😔", "😐", "🙂", "🤩"]; + + println!( + "{} {:<30}: {}", + SCORES[judge_output.score.min(5) as usize], + example_name, + judge_output.score, + ); + judge_scores.push(judge_output.score); + } + } + } + + let score_count = judge_scores.len(); + let average_score = judge_scores + .into_iter() + .map(|score| score as f32) + .sum::() + / (score_count as f32); + println!("\nAverage score: {average_score}"); + + cx.update(|cx| cx.quit()) }) .detach_and_log_err(cx); }); } +async fn run_example( + mut example: Example, + model: Arc, + app_state: Arc, + cx: &mut AsyncApp, +) -> Result { + example.setup().await?; + cx.update(|cx| example.run(model.clone(), app_state, cx))? + .await?; + let diff = example.repository_diff().await?; + example.judge(model, diff, cx).await +} + +fn list_all_examples() -> Result> { + let path = std::fs::canonicalize(EXAMPLES_DIR).unwrap(); + let entries = std::fs::read_dir(path).unwrap(); + let mut result_paths = Vec::new(); + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + result_paths.push(path); + } + } + Ok(result_paths) +} + /// Subset of `workspace::AppState` needed by `HeadlessAssistant`, with additional fields. pub struct AgentAppState { pub languages: Arc, @@ -72,6 +240,27 @@ pub fn init(cx: &mut App) -> Arc { .unwrap(); cx.set_global(settings_store); client::init_settings(cx); + + // Set User-Agent so we can download language servers from GitHub + let user_agent = format!( + "Zed/{} ({}; {})", + AppVersion::global(cx), + std::env::consts::OS, + std::env::consts::ARCH + ); + let proxy_str = ProxySettings::get_global(cx).proxy.to_owned(); + let proxy_url = proxy_str + .as_ref() + .and_then(|input| input.parse::().ok()) + .or_else(read_proxy_from_env); + let http = { + let _guard = Tokio::handle(cx).enter(); + + ReqwestClient::proxy_and_user_agent(proxy_url, &user_agent) + .expect("could not start HTTP client") + }; + cx.set_http_client(Arc::new(http)); + Project::init_settings(cx); let client = Client::production(cx); @@ -83,13 +272,47 @@ pub fn init(cx: &mut App) -> Arc { cx.background_executor().clone(), )); - let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); + let mut languages = LanguageRegistry::new(cx.background_executor().clone()); + languages.set_language_server_download_dir(paths::languages_dir().clone()); + let languages = Arc::new(languages); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + extension::init(cx); + + let (tx, rx) = async_watch::channel(None); + cx.observe_global::(move |cx| { + let settings = &ProjectSettings::get_global(cx).node; + let options = NodeBinaryOptions { + allow_path_lookup: !settings.ignore_system_version.unwrap_or_default(), + allow_binary_download: true, + use_paths: settings.path.as_ref().map(|node_path| { + let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref()); + let npm_path = settings + .npm_path + .as_ref() + .map(|path| PathBuf::from(shellexpand::tilde(&path).as_ref())); + ( + node_path.clone(), + npm_path.unwrap_or_else(|| { + let base_path = PathBuf::new(); + node_path.parent().unwrap_or(&base_path).join("npm") + }), + ) + }), + }; + tx.send(Some(options)).log_err(); + }) + .detach(); + let node_runtime = NodeRuntime::new(client.http_client().clone(), rx); + + let extension_host_proxy = ExtensionHostProxy::global(cx); + language::init(cx); + language_extension::init(extension_host_proxy.clone(), languages.clone()); language_model::init(client.clone(), cx); language_models::init(user_store.clone(), client.clone(), fs.clone(), cx); + languages::init(languages.clone(), node_runtime.clone(), cx); assistant_tools::init(client.http_client().clone(), cx); context_server::init(cx); let stdout_is_a_pty = false; @@ -109,7 +332,7 @@ pub fn init(cx: &mut App) -> Arc { client, user_store, fs, - node_runtime: NodeRuntime::unavailable(), + node_runtime, prompt_builder, }) } diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 6b45eddb60..c1ffaa51fe 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -1,83 +1,161 @@ use agent::{RequestKind, ThreadEvent, ThreadStore}; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use assistant_tool::ToolWorkingSet; +use client::proto::LspWorkProgress; use dap::DapRegistry; -use futures::channel::oneshot; -use gpui::{App, Task}; -use language_model::{LanguageModel, StopReason}; -use project::Project; -use serde::Deserialize; -use std::process::Command; -use std::sync::Arc; +use futures::channel::{mpsc, oneshot}; +use futures::{FutureExt, StreamExt as _}; +use gpui::{App, AsyncApp, Entity, Task}; +use handlebars::Handlebars; +use language::{DiagnosticSeverity, OffsetRangeExt}; +use language_model::{ + LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, + StopReason, TokenUsage, +}; +use project::{LspStore, Project, ProjectPath}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; +use std::fs::File; +use std::io::Write as _; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use std::{ fs, path::{Path, PathBuf}, }; +use unindent::Unindent as _; +use util::ResultExt as _; +use util::command::new_smol_command; +use util::serde::default_true; use crate::AgentAppState; -#[derive(Debug, Deserialize)] +pub const EXAMPLES_DIR: &str = "./crates/eval/examples"; +pub const REPOS_DIR: &str = "./crates/eval/repos"; +pub const WORKTREES_DIR: &str = "./crates/eval/worktrees"; + +#[derive(Clone, Debug, Deserialize)] pub struct ExampleBase { - pub path: PathBuf, + pub url: String, pub revision: String, + pub language_extension: Option, + pub insert_id: Option, + #[serde(default = "default_true")] + pub require_lsp: bool, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Example { + pub name: String, + /// Content of `base.toml` pub base: ExampleBase, - - /// Content of the prompt.md file + /// Content of `prompt.md` pub prompt: String, + /// Content of `criteria.md` + pub criteria: String, + /// Markdown log file to append to + pub log_file: Arc>, +} - /// Content of the rubric.md file - pub _rubric: String, +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RunOutput { + pub repository_diff: String, + pub diagnostics: String, + pub response_count: usize, + pub token_usage: TokenUsage, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JudgeInput { + pub repository_diff: String, + pub criteria: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JudgeOutput { + pub analysis: String, + pub score: u32, } impl Example { - /// Load an example from a directory containing base.toml, prompt.md, and rubric.md - pub fn load_from_directory>(dir_path: P) -> Result { - let base_path = dir_path.as_ref().join("base.toml"); - let prompt_path = dir_path.as_ref().join("prompt.md"); - let rubric_path = dir_path.as_ref().join("rubric.md"); + /// Load an example from a directory containing base.toml, prompt.md, and criteria.md + pub fn load_from_directory(dir_path: &Path, run_dir: &Path) -> Result { + let name = dir_path.file_name().unwrap().to_string_lossy().to_string(); + let base_path = dir_path.join("base.toml"); + let prompt_path = dir_path.join("prompt.md"); + let criteria_path = dir_path.join("criteria.md"); - let mut base: ExampleBase = toml::from_str(&fs::read_to_string(&base_path)?)?; - base.path = base.path.canonicalize()?; + let log_file_path = run_dir.join(format!( + "{}.md", + dir_path.file_name().unwrap().to_str().unwrap() + )); + let log_file = Arc::new(Mutex::new(File::create(&log_file_path).unwrap())); + println!("{}> Logging to {:?}", name, log_file_path); Ok(Example { - base, - prompt: fs::read_to_string(prompt_path)?, - _rubric: fs::read_to_string(rubric_path)?, + name, + base: toml::from_str(&fs::read_to_string(&base_path)?)?, + prompt: fs::read_to_string(prompt_path.clone())?, + criteria: fs::read_to_string(criteria_path.clone())?, + log_file, }) } - /// Set up the example by checking out the specified Git revision - pub fn setup(&self) -> Result<()> { - // Check if the directory exists - let path = Path::new(&self.base.path); - anyhow::ensure!(path.exists(), "Path does not exist: {:?}", self.base.path); + pub fn worktree_path(&self) -> PathBuf { + Path::new(WORKTREES_DIR) + .canonicalize() + .context(format!("No such directory {WORKTREES_DIR}")) + .unwrap() + .join(&self.name) + } - // Change to the project directory and checkout the specified revision - let output = Command::new("git") - .current_dir(&self.base.path) - .arg("checkout") - .arg(&self.base.revision) - .output()?; - anyhow::ensure!( - output.status.success(), - "Failed to checkout revision {}: {}", - self.base.revision, - String::from_utf8_lossy(&output.stderr), - ); + /// Set up the example by checking out the specified Git revision + pub async fn setup(&self) -> Result<()> { + let repo_path = repo_path_for_url(&self.base.url); + + run_git( + &repo_path, + &["fetch", "--depth", "1", "origin", &self.base.revision], + ) + .await?; + + let worktree_path = self.worktree_path(); + + if worktree_path.is_dir() { + println!("{}> Resetting existing worktree", self.name); + + // TODO: consider including "-x" to remove ignored files. The downside of this is that + // it will also remove build artifacts, and so prevent incremental reuse there. + run_git(&worktree_path, &["clean", "--force", "-d"]).await?; + run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; + run_git(&worktree_path, &["checkout", &self.base.revision]).await?; + } else { + println!("{}> Creating worktree", self.name); + + let worktree_path_string = worktree_path.to_string_lossy().to_string(); + + run_git( + &repo_path, + &[ + "worktree", + "add", + "-f", + &worktree_path_string, + &self.base.revision, + ], + ) + .await?; + } Ok(()) } pub fn run( - self, + &self, model: Arc, app_state: Arc, cx: &mut App, - ) -> Task> { + ) -> Task> { let project = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -89,30 +167,119 @@ impl Example { cx, ); + let worktree_path = self.worktree_path(); let worktree = project.update(cx, |project, cx| { - project.create_worktree(self.base.path, true, cx) + project.create_worktree(&worktree_path, true, cx) }); let tools = Arc::new(ToolWorkingSet::default()); let thread_store = ThreadStore::load(project.clone(), tools, app_state.prompt_builder.clone(), cx); + let this = self.clone(); - println!("USER:"); - println!("{}", self.prompt); - println!("ASSISTANT:"); cx.spawn(async move |cx| { - worktree.await?; + let worktree = worktree.await?; + + // Wait for worktree scan to finish before choosing a file to open. + worktree + .update(cx, |worktree, _cx| { + worktree.as_local().unwrap().scan_complete() + })? + .await; + + let lsp_open_handle_and_store = if this.base.require_lsp { + let language_extension = this.base.language_extension.as_deref().context( + "language_extension field is required in base.toml when `require_lsp == true`", + )?; + + // Open a file that matches the language to cause LSP to start. + let language_file = worktree.read_with(cx, |worktree, _cx| { + worktree + .files(false, 0) + .find_map(|e| { + if e.path.clone().extension().and_then(|ext| ext.to_str()) + == Some(language_extension) + { + Some(ProjectPath { + worktree_id: worktree.id(), + path: e.path.clone(), + }) + } else { + None + } + }) + .context("Failed to find a file for example language") + })??; + + let open_language_file_buffer_task = project.update(cx, |project, cx| { + project.open_buffer(language_file.clone(), cx) + })?; + + let language_file_buffer = open_language_file_buffer_task.await?; + + let (lsp_open_handle, lsp_store) = project.update(cx, |project, cx| { + ( + project.register_buffer_with_language_servers(&language_file_buffer, cx), + project.lsp_store().clone(), + ) + })?; + + // TODO: remove this once the diagnostics tool waits for new diagnostics + cx.background_executor().timer(Duration::new(5, 0)).await; + wait_for_lang_server(&lsp_store, this.name.clone(), cx).await?; + + lsp_store.update(cx, |lsp_store, cx| { + lsp_open_handle.update(cx, |buffer, cx| { + buffer.update(cx, |buffer, cx| { + let has_language_server = lsp_store + .language_servers_for_local_buffer(buffer, cx) + .next() + .is_some(); + if has_language_server { + Ok(()) + } else { + Err(anyhow!( + "`{:?}` was opened to cause the language server to start, \ + but no language servers are registered for its buffer. \ + Set `require_lsp = false` in `base.toml` to skip this.", + language_file + )) + } + }) + }) + })??; + + Some((lsp_open_handle, lsp_store)) + } else { + None + }; + + if std::env::var("ZED_EVAL_SETUP_ONLY").is_ok() { + return Err(anyhow!("Setup only mode")); + } + let thread_store = thread_store.await; let thread = thread_store.update(cx, |thread_store, cx| thread_store.create_thread(cx))?; + { + let mut log_file = this.log_file.lock().unwrap(); + writeln!(&mut log_file, "👤 USER:").log_err(); + writeln!(&mut log_file, "{}", this.prompt).log_err(); + writeln!(&mut log_file, "🤖 ASSISTANT:").log_err(); + log_file.flush().log_err(); + } + let (tx, rx) = oneshot::channel(); let mut tx = Some(tx); - let _subscription = - cx.subscribe( - &thread, - move |thread, event: &ThreadEvent, cx| match event { + let _subscription = cx.subscribe(&thread, { + let log_file = this.log_file.clone(); + let name = this.name.clone(); + move |thread, event: &ThreadEvent, cx| { + let mut log_file = log_file.lock().unwrap(); + + match event { ThreadEvent::Stopped(reason) => match reason { Ok(StopReason::EndTurn) => { if let Some(tx) = tx.take() { @@ -137,15 +304,16 @@ impl Example { } } ThreadEvent::StreamedAssistantText(_, chunk) => { - print!("{}", chunk); + write!(&mut log_file, "{}", chunk).log_err(); } ThreadEvent::StreamedAssistantThinking(_, chunk) => { - print!("{}", chunk); + write!(&mut log_file, "{}", chunk).log_err(); } ThreadEvent::UsePendingTools { tool_uses } => { - println!("\n\nUSING TOOLS:"); + writeln!(&mut log_file, "\n\nUSING TOOLS:").log_err(); for tool_use in tool_uses { - println!("{}: {}", tool_use.name, tool_use.input); + writeln!(&mut log_file, "{}: {}", tool_use.name, tool_use.input) + .log_err(); } } ThreadEvent::ToolFinished { @@ -154,25 +322,331 @@ impl Example { .. } => { if let Some(tool_use) = pending_tool_use { - println!("\nTOOL FINISHED: {}", tool_use.name); + let message = format!("TOOL FINISHED: {}", tool_use.name); + println!("{name}> {message}"); + writeln!(&mut log_file, "\n{}", message).log_err(); } if let Some(tool_result) = thread.read(cx).tool_result(tool_use_id) { - println!("\n{}\n", tool_result.content); + let message = format!("\n{}\n", tool_result.content); + writeln!(&mut log_file, "{}", message).log_err(); } } _ => {} - }, - )?; + } + + log_file.flush().log_err(); + } + })?; thread.update(cx, |thread, cx| { let context = vec![]; - thread.insert_user_message(self.prompt.clone(), context, None, cx); + thread.insert_user_message(this.prompt.clone(), context, None, cx); thread.send_to_model(model, RequestKind::Chat, cx); })?; rx.await??; - Ok(()) + if let Some((_, lsp_store)) = lsp_open_handle_and_store.as_ref() { + wait_for_lang_server(lsp_store, this.name.clone(), cx).await?; + } + + let repository_diff = this.repository_diff().await?; + let diagnostics = cx + .update(move |cx| { + cx.spawn(async move |cx| query_lsp_diagnostics(project, cx).await) + })? + .await?; + + drop(lsp_open_handle_and_store); + + thread.update(cx, |thread, _cx| { + let response_count = thread + .messages() + .filter(|message| message.role == language_model::Role::Assistant) + .count(); + RunOutput { + repository_diff, + diagnostics, + response_count, + token_usage: thread.cumulative_token_usage(), + } + }) }) } + + pub async fn judge( + &mut self, + model: Arc, + repository_diff: String, + cx: &AsyncApp, + ) -> Result { + let judge_prompt = include_str!("judge_prompt.hbs"); + let judge_prompt_name = "judge_prompt"; + let mut handlebars = Handlebars::new(); + handlebars.register_template_string(judge_prompt_name, judge_prompt)?; + let prompt = handlebars.render( + judge_prompt_name, + &JudgeInput { + repository_diff, + criteria: self.criteria.clone(), + }, + )?; + + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text(prompt)], + cache: false, + }], + temperature: None, + tools: Vec::new(), + stop: Vec::new(), + }; + + let response = send_language_model_request(model, request, cx).await?; + + let mut log_file = self.log_file.lock().unwrap(); + + writeln!(&mut log_file, "\n\n").log_err(); + writeln!(&mut log_file, "========================================").log_err(); + writeln!(&mut log_file, " JUDGE OUTPUT ").log_err(); + writeln!(&mut log_file, "========================================").log_err(); + writeln!(&mut log_file, "\n{}", &response).log_err(); + + parse_judge_output(&response) + } + + pub async fn repository_diff(&self) -> Result { + let worktree_path = self.worktree_path(); + run_git(&worktree_path, &["add", "-N"]).await?; + run_git(&worktree_path, &["diff"]).await + } +} + +fn wait_for_lang_server( + lsp_store: &Entity, + name: String, + cx: &mut AsyncApp, +) -> Task> { + if cx + .update(|cx| !has_pending_lang_server_work(lsp_store, cx)) + .unwrap() + || std::env::var("ZED_EVAL_SKIP_LS_WAIT").is_ok() + { + return Task::ready(anyhow::Ok(())); + } + + println!("{}> ⏵ Waiting for language server", name); + + let (mut tx, mut rx) = mpsc::channel(1); + + let subscription = + cx.subscribe(&lsp_store, { + let name = name.clone(); + move |lsp_store, event, cx| { + match event { + project::LspStoreEvent::LanguageServerUpdate { + message: + client::proto::update_language_server::Variant::WorkProgress( + LspWorkProgress { + message: Some(message), + .. + }, + ), + .. + } => println!("{name}> ⟲ {message}"), + _ => {} + } + + if !has_pending_lang_server_work(&lsp_store, cx) { + tx.try_send(()).ok(); + } + } + }); + + cx.spawn(async move |cx| { + let timeout = cx.background_executor().timer(Duration::new(60 * 5, 0)); + let result = futures::select! { + _ = rx.next() => { + println!("{}> ⚑ Language server idle", name); + anyhow::Ok(()) + }, + _ = timeout.fuse() => { + Err(anyhow!("LSP wait timed out after 5 minutes")) + } + }; + drop(subscription); + result + }) +} + +fn has_pending_lang_server_work(lsp_store: &Entity, cx: &App) -> bool { + lsp_store + .read(cx) + .language_server_statuses() + .any(|(_, status)| !status.pending_work.is_empty()) +} + +async fn query_lsp_diagnostics(project: Entity, cx: &mut AsyncApp) -> Result { + let paths_with_diagnostics = project.update(cx, |project, cx| { + project + .diagnostic_summaries(true, cx) + .filter(|(_, _, summary)| summary.error_count > 0 || summary.warning_count > 0) + .map(|(project_path, _, _)| project_path) + .collect::>() + })?; + + let mut output = String::new(); + for project_path in paths_with_diagnostics { + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx))? + .await?; + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + + for (_, group) in snapshot.diagnostic_groups(None) { + let entry = &group.entries[group.primary_ix]; + let range = entry.range.to_point(&snapshot); + let severity = match entry.diagnostic.severity { + DiagnosticSeverity::ERROR => "error", + DiagnosticSeverity::WARNING => "warning", + _ => continue, + }; + + writeln!( + output, + "{} at line {}: {}", + severity, + range.start.row + 1, + entry.diagnostic.message + )?; + } + } + anyhow::Ok(output) +} + +fn parse_judge_output(response: &str) -> Result { + let analysis = get_tag("analysis", response)?.to_string(); + let score = get_tag("score", response)? + .parse() + .context("error parsing score")?; + + Ok(JudgeOutput { analysis, score }) +} + +fn get_tag(name: &'static str, response: &str) -> Result { + let start_tag = format!("<{}>", name); + let end_tag = format!("", name); + + let start_ix = response + .find(&start_tag) + .context(format!("{} start tag not found", name))?; + let content_start_ix = start_ix + start_tag.len(); + + let end_ix = content_start_ix + + response[content_start_ix..] + .find(&end_tag) + .context(format!("{} end tag not found", name))?; + + let content = response[content_start_ix..end_ix].trim().unindent(); + + anyhow::Ok(content) +} + +pub fn repo_path_for_url(repo_url: &str) -> PathBuf { + let repo_name = repo_url + .trim_start_matches("https://") + .replace(|c: char| !c.is_alphanumeric(), "-"); + Path::new(REPOS_DIR) + .canonicalize() + .context(format!("No such directory {REPOS_DIR}")) + .unwrap() + .join(repo_name) +} + +pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = new_smol_command("git") + .current_dir(repo_path) + .args(args) + .output() + .await?; + + if output.status.success() { + Ok(String::from_utf8(output.stdout)?.trim().to_string()) + } else { + Err(anyhow!( + "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", + args.join(" "), + repo_path.display(), + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + )) + } +} + +pub async fn send_language_model_request( + model: Arc, + request: LanguageModelRequest, + cx: &AsyncApp, +) -> anyhow::Result { + match model.stream_completion_text(request, &cx).await { + Ok(mut stream) => { + let mut full_response = String::new(); + while let Some(chunk_result) = stream.stream.next().await { + match chunk_result { + Ok(chunk_str) => { + print!("{}", &chunk_str); + full_response.push_str(&chunk_str); + } + Err(err) => { + return Err(anyhow!( + "Error receiving response from language model: {err}" + )); + } + } + } + Ok(full_response) + } + Err(err) => Err(anyhow!( + "Failed to get response from language model. Error was: {err}" + )), + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_parse_judge_output() { + let response = r#" + The model did a good job but there were still compilations errors. + 3 + "# + .unindent(); + + let output = parse_judge_output(&response).unwrap(); + assert_eq!( + output.analysis, + "The model did a good job but there were still compilations errors." + ); + assert_eq!(output.score, 3); + + let response = r#" + Text around ignored + + + Failed to compile: + - Error 1 + - Error 2 + + + 1 + "# + .unindent(); + + let output = parse_judge_output(&response).unwrap(); + assert_eq!(output.analysis, "Failed to compile:\n- Error 1\n- Error 2"); + assert_eq!(output.score, 1); + } } diff --git a/crates/eval/src/judge_prompt.hbs b/crates/eval/src/judge_prompt.hbs new file mode 100644 index 0000000000..cce120d52a --- /dev/null +++ b/crates/eval/src/judge_prompt.hbs @@ -0,0 +1,25 @@ +You are an expert software developer tasked with evaluating the following changes to a codebase: + + +{{repository_diff}} + + +Use the following criteria to score the above changes. + + +{{criteria}} + + +Based on these criteria, give the test output a score between 0 and 5. + +- 5 means: changes meet all criteria +- 0 means: changes don't meet any criteria + +Be suspicious of the changes because they were generated by an LLM. +Sometimes the LLM decides to change random code, so if the changes are not mentioned in the criteria, penalize the score. +Analyze the diff hunk by hunk and describe how each change meets or fails to meet the criteria. + +``` +{YOUR ANALYSIS HERE} +{YOUR SCORE HERE} +``` diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index e1ec23410e..a0e38c629e 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -83,7 +83,7 @@ pub enum StopReason { ToolUse, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Default)] pub struct TokenUsage { #[serde(default, skip_serializing_if = "is_default")] pub input_tokens: u32, From 9d919082567ffb6fd4c06228288ce28f375ba13c Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Mon, 14 Apr 2025 16:21:19 -0400 Subject: [PATCH 55/75] Bump Zed to v0.183 (#28718) Version was bumped to `v0.183.0` last Wednesday here: https://github.com/zed-industries/zed/pull/28419 But was accidentally downgraded here: https://github.com/zed-industries/zed/pull/27964 Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73761469b9..c5f8742e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18136,7 +18136,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.182.0" +version = "0.183.0" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d167739f3c..926b5c51ef 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.182.0" +version = "0.183.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From db56254517cccca4e8ad50ae35d18529377abc0a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 14 Apr 2025 14:23:42 -0600 Subject: [PATCH 56/75] agent: Apply soft-wrap when message editor is expanded (#28716) Release Notes: - N/A --- crates/agent/src/message_editor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 573e4b4d03..6c1c76b26e 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -93,6 +93,7 @@ impl MessageEditor { ); editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx); editor.set_show_indent_guides(false, cx); + editor.set_soft_wrap(); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, From 26b9c32e961adcabdf842ba734ef21f341869e53 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 14 Apr 2025 17:22:27 -0400 Subject: [PATCH 57/75] python: Auto-close f-strings (#28709) Closes #28707 Release Notes: - Added support for auto-closing `f`, `b`, `u`, `r`, `rb` and the newly released `t` strings in Python --- crates/editor/src/editor.rs | 14 ++++++++++---- crates/languages/src/python/config.toml | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c80670bd45..6e779ebed4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3169,6 +3169,7 @@ impl Editor { let mut is_bracket_pair_start = false; let mut is_bracket_pair_end = false; if !text.is_empty() { + let mut bracket_pair_matching_end = None; // `text` can be empty when a user is using IME (e.g. Chinese Wubi Simplified) // and they are removing the character that triggered IME popup. for (pair, enabled) in scope.brackets() { @@ -3193,12 +3194,17 @@ impl Editor { break; } } - if pair.end.as_str() == text.as_ref() { - bracket_pair = Some(pair.clone()); - is_bracket_pair_end = true; - break; + if pair.end.as_str() == text.as_ref() && bracket_pair_matching_end.is_none() + { + // take first bracket pair matching end, but don't break in case a later bracket + // pair matches start + bracket_pair_matching_end = Some(pair.clone()); } } + if bracket_pair.is_none() && bracket_pair_matching_end.is_some() { + bracket_pair = Some(bracket_pair_matching_end.unwrap()); + is_bracket_pair_end = true; + } } if let Some(bracket_pair) = bracket_pair { diff --git a/crates/languages/src/python/config.toml b/crates/languages/src/python/config.toml index 836059bf96..6749f39060 100644 --- a/crates/languages/src/python/config.toml +++ b/crates/languages/src/python/config.toml @@ -5,6 +5,18 @@ first_line_pattern = '^#!.*\bpython[0-9.]*\b' line_comments = ["# "] autoclose_before = ";:.,=}])>" brackets = [ + { start = "f\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "f'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "b\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "b'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "u\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "u'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "r\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "r'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "rb\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "rb'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "t\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, + { start = "t'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, { start = "\"\"\"", end = "\"\"\"", close = true, newline = false, not_in = ["string"] }, { start = "'''", end = "'''", close = true, newline = false, not_in = ["string"] }, { start = "{", end = "}", close = true, newline = true }, From c8ccc472b5984821bdd936c390825b01154684ba Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 14 Apr 2025 15:45:36 -0600 Subject: [PATCH 58/75] Track tool use counts (#28722) Release Notes: - N/A --- Cargo.lock | 1 + crates/eval/Cargo.toml | 1 + crates/eval/src/example.rs | 17 ++++++++++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5f8742e52..8cb318d242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4882,6 +4882,7 @@ dependencies = [ "chrono", "clap", "client", + "collections", "context_server", "dap", "env_logger 0.11.8", diff --git a/crates/eval/Cargo.toml b/crates/eval/Cargo.toml index 6828de36fc..42597393a1 100644 --- a/crates/eval/Cargo.toml +++ b/crates/eval/Cargo.toml @@ -14,6 +14,7 @@ assistant_tools.workspace = true chrono.workspace = true clap.workspace = true client.workspace = true +collections.workspace = true context_server.workspace = true dap.workspace = true env_logger.workspace = true diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index c1ffaa51fe..ff2f9282b1 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -2,6 +2,7 @@ use agent::{RequestKind, ThreadEvent, ThreadStore}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::ToolWorkingSet; use client::proto::LspWorkProgress; +use collections::HashMap; use dap::DapRegistry; use futures::channel::{mpsc, oneshot}; use futures::{FutureExt, StreamExt as _}; @@ -63,6 +64,7 @@ pub struct RunOutput { pub diagnostics: String, pub response_count: usize, pub token_usage: TokenUsage, + pub tool_use_counts: HashMap, u32>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -270,12 +272,16 @@ impl Example { log_file.flush().log_err(); } + let tool_use_counts: Arc, u32>>> = + Mutex::new(HashMap::default()).into(); + let (tx, rx) = oneshot::channel(); let mut tx = Some(tx); - let _subscription = cx.subscribe(&thread, { + let subscription = cx.subscribe(&thread, { let log_file = this.log_file.clone(); let name = this.name.clone(); + let tool_use_counts = tool_use_counts.clone(); move |thread, event: &ThreadEvent, cx| { let mut log_file = log_file.lock().unwrap(); @@ -327,8 +333,11 @@ impl Example { writeln!(&mut log_file, "\n{}", message).log_err(); } if let Some(tool_result) = thread.read(cx).tool_result(tool_use_id) { - let message = format!("\n{}\n", tool_result.content); - writeln!(&mut log_file, "{}", message).log_err(); + writeln!(&mut log_file, "\n{}\n", tool_result.content).log_err(); + let mut tool_use_counts = tool_use_counts.lock().unwrap(); + *tool_use_counts + .entry(tool_result.tool_name.clone()) + .or_insert(0) += 1; } } _ => {} @@ -357,6 +366,7 @@ impl Example { })? .await?; + drop(subscription); drop(lsp_open_handle_and_store); thread.update(cx, |thread, _cx| { @@ -369,6 +379,7 @@ impl Example { diagnostics, response_count, token_usage: thread.cumulative_token_usage(), + tool_use_counts: tool_use_counts.lock().unwrap().clone(), } }) }) From a8b1ef3531f8fccb32d1a8f58f8529ed595ccbee Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 14 Apr 2025 18:01:21 -0400 Subject: [PATCH 59/75] google_ai: Remove unused `extract_text_from_events` function (#28723) This PR removes the `extract_text_from_events` function from `google_ai`, as it was not used anywhere. Release Notes: - N/A --- crates/google_ai/src/google_ai.rs | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index cd9fb181d8..4ed22717fc 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -1,7 +1,7 @@ mod supported_countries; use anyhow::{Result, anyhow, bail}; -use futures::{AsyncBufReadExt, AsyncReadExt, Stream, StreamExt, io::BufReader, stream::BoxStream}; +use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; @@ -455,24 +455,3 @@ impl std::fmt::Display for Model { write!(f, "{}", self.id()) } } - -pub fn extract_text_from_events( - events: impl Stream>, -) -> impl Stream> { - events.filter_map(|event| async move { - match event { - Ok(event) => event.candidates.and_then(|candidates| { - candidates.into_iter().next().and_then(|candidate| { - candidate.content.parts.into_iter().next().and_then(|part| { - if let Part::TextPart(TextPart { text }) = part { - Some(Ok(text)) - } else { - None - } - }) - }) - }), - Err(error) => Some(Err(error)), - } - }) -} From d74f0735c2fa0e153d181a3edf3747e8238eee72 Mon Sep 17 00:00:00 2001 From: Thomas Mickley-Doyle Date: Mon, 14 Apr 2025 17:05:46 -0500 Subject: [PATCH 60/75] Add more eval examples + filtering examples by language + fix git concurrent usage (#28719) Release Notes: - N/A --------- Co-authored-by: michael Co-authored-by: agus --- Cargo.lock | 1 - crates/anthropic/Cargo.toml | 1 - crates/anthropic/src/anthropic.rs | 23 +++++++++++++++---- .../auth_session_management/base.toml | 3 +++ .../auth_session_management/criteria.md | 10 ++++++++ .../auth_session_management/prompt.md | 3 +++ .../examples/checkpoint_stability/base.toml | 3 +++ .../examples/checkpoint_stability/criteria.md | 5 ++++ .../examples/checkpoint_stability/prompt.md | 7 ++++++ .../base.toml | 3 +++ .../criteria.md | 5 ++++ .../prompt.md | 3 +++ .../examples/debian_image_builder/base.toml | 3 +++ .../examples/debian_image_builder/criteria.md | 4 ++++ .../examples/debian_image_builder/prompt.md | 1 + .../eval/examples/docs_restructure/base.toml | 3 +++ .../examples/docs_restructure/criteria.md | 12 ++++++++++ .../eval/examples/docs_restructure/prompt.md | 13 +++++++++++ .../expand_laravel_php_support/base.toml | 3 +++ .../expand_laravel_php_support/criteria.md | 3 +++ .../expand_laravel_php_support/prompt.md | 11 +++++++++ .../examples/finnish_translation/base.toml | 3 +++ .../examples/finnish_translation/criteria.md | 12 ++++++++++ .../examples/finnish_translation/prompt.md | 5 ++++ .../language_model_file_support/base.toml | 3 +++ .../language_model_file_support/criteria.md | 3 +++ .../language_model_file_support/prompt.md | 1 + .../examples/license_management/base.toml | 3 +++ .../examples/license_management/criteria.md | 3 +++ .../examples/license_management/prompt.md | 17 ++++++++++++++ .../eval/examples/metal_i64_support/base.toml | 3 +++ .../examples/metal_i64_support/criteria.md | 4 ++++ .../eval/examples/metal_i64_support/prompt.md | 1 + .../eval/examples/nan_diff_handling/base.toml | 3 +++ .../examples/nan_diff_handling/criteria.md | 6 +++++ .../eval/examples/nan_diff_handling/prompt.md | 1 + .../optimizer_schema_refactor/base.toml | 3 +++ .../optimizer_schema_refactor/criteria.md | 3 +++ .../optimizer_schema_refactor/prompt.md | 1 + .../examples/rate_limit_endpoints/base.toml | 3 +++ .../examples/rate_limit_endpoints/criteria.md | 12 ++++++++++ .../examples/rate_limit_endpoints/prompt.md | 18 +++++++++++++++ .../request_to_axios_migration/base.toml | 3 +++ .../request_to_axios_migration/criteria.md | 3 +++ .../request_to_axios_migration/prompt.md | 1 + .../runtime_script_refactor/base.toml | 3 +++ .../runtime_script_refactor/criteria.md | 6 +++++ .../runtime_script_refactor/prompt.md | 7 ++++++ .../base.toml | 3 +++ .../criteria.md | 7 ++++++ .../prompt.md | 1 + .../examples/table_metrics_sorting/base.toml | 3 +++ .../table_metrics_sorting/criteria.md | 5 ++++ .../examples/table_metrics_sorting/prompt.md | 1 + .../eval/examples/tax_id_validation/base.toml | 3 +++ .../examples/tax_id_validation/criteria.md | 3 +++ .../eval/examples/tax_id_validation/prompt.md | 10 ++++++++ .../examples/test_infrastructure/base.toml | 3 +++ .../examples/test_infrastructure/criteria.md | 3 +++ .../examples/test_infrastructure/prompt.md | 1 + .../examples/tool_response_handling/base.toml | 3 +++ .../tool_response_handling/criteria.md | 3 +++ .../examples/tool_response_handling/prompt.md | 1 + .../eval/examples/toolbar_endpoints/base.toml | 3 +++ .../examples/toolbar_endpoints/criteria.md | 3 +++ .../eval/examples/toolbar_endpoints/prompt.md | 3 +++ .../war_and_uri_corrections/base.toml | 3 +++ .../war_and_uri_corrections/criteria.md | 7 ++++++ .../war_and_uri_corrections/prompt.md | 7 ++++++ .../examples/window_title_support/base.toml | 3 +++ .../examples/window_title_support/criteria.md | 4 ++++ .../examples/window_title_support/prompt.md | 11 +++++++++ crates/eval/src/eval.rs | 21 ++++++++++++++++- crates/eval/src/example.rs | 2 ++ crates/eval/src/judge_prompt.hbs | 1 + typos.toml | 4 ++++ 76 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 crates/eval/examples/auth_session_management/base.toml create mode 100644 crates/eval/examples/auth_session_management/criteria.md create mode 100644 crates/eval/examples/auth_session_management/prompt.md create mode 100644 crates/eval/examples/checkpoint_stability/base.toml create mode 100644 crates/eval/examples/checkpoint_stability/criteria.md create mode 100644 crates/eval/examples/checkpoint_stability/prompt.md create mode 100644 crates/eval/examples/dd_iaptic_mcp_server_integration/base.toml create mode 100644 crates/eval/examples/dd_iaptic_mcp_server_integration/criteria.md create mode 100644 crates/eval/examples/dd_iaptic_mcp_server_integration/prompt.md create mode 100644 crates/eval/examples/debian_image_builder/base.toml create mode 100644 crates/eval/examples/debian_image_builder/criteria.md create mode 100644 crates/eval/examples/debian_image_builder/prompt.md create mode 100644 crates/eval/examples/docs_restructure/base.toml create mode 100644 crates/eval/examples/docs_restructure/criteria.md create mode 100644 crates/eval/examples/docs_restructure/prompt.md create mode 100644 crates/eval/examples/expand_laravel_php_support/base.toml create mode 100644 crates/eval/examples/expand_laravel_php_support/criteria.md create mode 100644 crates/eval/examples/expand_laravel_php_support/prompt.md create mode 100644 crates/eval/examples/finnish_translation/base.toml create mode 100644 crates/eval/examples/finnish_translation/criteria.md create mode 100644 crates/eval/examples/finnish_translation/prompt.md create mode 100644 crates/eval/examples/language_model_file_support/base.toml create mode 100644 crates/eval/examples/language_model_file_support/criteria.md create mode 100644 crates/eval/examples/language_model_file_support/prompt.md create mode 100644 crates/eval/examples/license_management/base.toml create mode 100644 crates/eval/examples/license_management/criteria.md create mode 100644 crates/eval/examples/license_management/prompt.md create mode 100644 crates/eval/examples/metal_i64_support/base.toml create mode 100644 crates/eval/examples/metal_i64_support/criteria.md create mode 100644 crates/eval/examples/metal_i64_support/prompt.md create mode 100644 crates/eval/examples/nan_diff_handling/base.toml create mode 100644 crates/eval/examples/nan_diff_handling/criteria.md create mode 100644 crates/eval/examples/nan_diff_handling/prompt.md create mode 100644 crates/eval/examples/optimizer_schema_refactor/base.toml create mode 100644 crates/eval/examples/optimizer_schema_refactor/criteria.md create mode 100644 crates/eval/examples/optimizer_schema_refactor/prompt.md create mode 100644 crates/eval/examples/rate_limit_endpoints/base.toml create mode 100644 crates/eval/examples/rate_limit_endpoints/criteria.md create mode 100644 crates/eval/examples/rate_limit_endpoints/prompt.md create mode 100644 crates/eval/examples/request_to_axios_migration/base.toml create mode 100644 crates/eval/examples/request_to_axios_migration/criteria.md create mode 100644 crates/eval/examples/request_to_axios_migration/prompt.md create mode 100644 crates/eval/examples/runtime_script_refactor/base.toml create mode 100644 crates/eval/examples/runtime_script_refactor/criteria.md create mode 100644 crates/eval/examples/runtime_script_refactor/prompt.md create mode 100644 crates/eval/examples/standardized_docker_dependency_checks/base.toml create mode 100644 crates/eval/examples/standardized_docker_dependency_checks/criteria.md create mode 100644 crates/eval/examples/standardized_docker_dependency_checks/prompt.md create mode 100644 crates/eval/examples/table_metrics_sorting/base.toml create mode 100644 crates/eval/examples/table_metrics_sorting/criteria.md create mode 100644 crates/eval/examples/table_metrics_sorting/prompt.md create mode 100644 crates/eval/examples/tax_id_validation/base.toml create mode 100644 crates/eval/examples/tax_id_validation/criteria.md create mode 100644 crates/eval/examples/tax_id_validation/prompt.md create mode 100644 crates/eval/examples/test_infrastructure/base.toml create mode 100644 crates/eval/examples/test_infrastructure/criteria.md create mode 100644 crates/eval/examples/test_infrastructure/prompt.md create mode 100644 crates/eval/examples/tool_response_handling/base.toml create mode 100644 crates/eval/examples/tool_response_handling/criteria.md create mode 100644 crates/eval/examples/tool_response_handling/prompt.md create mode 100644 crates/eval/examples/toolbar_endpoints/base.toml create mode 100644 crates/eval/examples/toolbar_endpoints/criteria.md create mode 100644 crates/eval/examples/toolbar_endpoints/prompt.md create mode 100644 crates/eval/examples/war_and_uri_corrections/base.toml create mode 100644 crates/eval/examples/war_and_uri_corrections/criteria.md create mode 100644 crates/eval/examples/war_and_uri_corrections/prompt.md create mode 100644 crates/eval/examples/window_title_support/base.toml create mode 100644 crates/eval/examples/window_title_support/criteria.md create mode 100644 crates/eval/examples/window_title_support/prompt.md diff --git a/Cargo.lock b/Cargo.lock index 8cb318d242..b1c75fe3f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,7 +326,6 @@ dependencies = [ "serde_json", "strum", "thiserror 2.0.12", - "util", "workspace-hack", ] diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index 1735579729..8e82c7cdd6 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -25,5 +25,4 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true -util.workspace = true workspace-hack.workspace = true diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 2e403fd0fa..266d3c7642 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -10,7 +10,6 @@ use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use strum::{EnumIter, EnumString}; use thiserror::Error; -use util::ResultExt as _; pub use supported_countries::*; @@ -363,11 +362,25 @@ pub struct RateLimitInfo { impl RateLimitInfo { fn from_headers(headers: &HeaderMap) -> Self { + // Check if any rate limit headers exist + let has_rate_limit_headers = headers + .keys() + .any(|k| k.as_str().starts_with("anthropic-ratelimit-")); + + if !has_rate_limit_headers { + return Self { + requests: None, + tokens: None, + input_tokens: None, + output_tokens: None, + }; + } + Self { - requests: RateLimit::from_headers("requests", headers).log_err(), - tokens: RateLimit::from_headers("tokens", headers).log_err(), - input_tokens: RateLimit::from_headers("input-tokens", headers).log_err(), - output_tokens: RateLimit::from_headers("output-tokens", headers).log_err(), + requests: RateLimit::from_headers("requests", headers).ok(), + tokens: RateLimit::from_headers("tokens", headers).ok(), + input_tokens: RateLimit::from_headers("input-tokens", headers).ok(), + output_tokens: RateLimit::from_headers("output-tokens", headers).ok(), } } } diff --git a/crates/eval/examples/auth_session_management/base.toml b/crates/eval/examples/auth_session_management/base.toml new file mode 100644 index 0000000000..f34b9a0e44 --- /dev/null +++ b/crates/eval/examples/auth_session_management/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/workos/authkit-js.git" +revision = "949345d85782a93e8f1738ec31823948ffc26301" +language_extension = "ts" diff --git a/crates/eval/examples/auth_session_management/criteria.md b/crates/eval/examples/auth_session_management/criteria.md new file mode 100644 index 0000000000..cfb483450c --- /dev/null +++ b/crates/eval/examples/auth_session_management/criteria.md @@ -0,0 +1,10 @@ +1. Add a new test case in `create-client.test.ts` for when the `returnTo` option is provided during sign-out. It verifies that the sign-out URL includes the correct `return_to` query parameter with the provided URL. The test sets up a mock client, calls signOut with a returnTo value, and asserts that the resulting URL contains the expected session_id and return_to parameters while maintaining the correct API endpoint structure. +2. Modifies the `signOut` method in `create-client.ts` to accept an optional options parameter containing a returnTo string. Instead of directly passing the sessionId to getLogoutUrl, it now passes an object containing both the sessionId and the returnTo value from the options. The method maintains its existing behavior of checking for an access token and clearing session data when a URL is available. +3. Updates the HTTP client tests in `http-client.test.ts` to reflect the new getLogoutUrl signature. It adds a test case for the basic logout URL and a new describe block for when returnTo is provided, verifying that the URL includes the properly encoded return_to parameter. The test ensures the URL construction handles both cases correctly. +4. Modifies the `getLogoutUrl` method in `http-client.ts` to accept an object parameter with sessionId and returnTo properties instead of just a sessionId string. It maintains the base URL construction but now conditionally adds the return_to query parameter only when a returnTo value is provided, while always including the session_id parameter. The method handles URL construction and parameter encoding internally. +5. Updates the session initialization logic in `create-client.ts` to check for either a `workos-has-session` cookie or a refresh token (retrieved via `getRefreshToken`). This allows the client to refresh sessions even if no `code` is present in the URL, especially in development environments. +6. Adds corresponding test coverage in `create-client.test.ts`: + - When no code is in the URL but the `workos-has-session` cookie exists, the session should be refreshed. + - When devMode is enabled and a refresh token is present in localStorage, the session should be refreshed. + - When devMode is enabled but no refresh token exists, the client should be created without making any network requests. + - When neither a code, cookie, nor refresh token is present, the client should initialize without refreshing. diff --git a/crates/eval/examples/auth_session_management/prompt.md b/crates/eval/examples/auth_session_management/prompt.md new file mode 100644 index 0000000000..19081fa060 --- /dev/null +++ b/crates/eval/examples/auth_session_management/prompt.md @@ -0,0 +1,3 @@ +I need to improve our logout feature. When users sign out, they should be able to specify a return URL to redirect to afterward. Right now, signing out just takes them to a default page, but we want to support custom redirects (like back to the homepage or a login screen). The URL should be safely included in the logout request. Make sure existing logouts still work normally when no redirect is specified. + +Also, note that we updated how the client initializes its session. It should now check for either a `workos-has-session` cookie or a valid refresh token (even in devMode). This ensures that sessions are refreshed appropriately even without a code in the URL. Be sure this logic is covered by the minimum tests. diff --git a/crates/eval/examples/checkpoint_stability/base.toml b/crates/eval/examples/checkpoint_stability/base.toml new file mode 100644 index 0000000000..bdd1e912d0 --- /dev/null +++ b/crates/eval/examples/checkpoint_stability/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/cline/cline.git" +revision = "a26494e5cc453f9c7e148d35895fda3f74d03284" +language_extension = "ts" diff --git a/crates/eval/examples/checkpoint_stability/criteria.md b/crates/eval/examples/checkpoint_stability/criteria.md new file mode 100644 index 0000000000..b104713a24 --- /dev/null +++ b/crates/eval/examples/checkpoint_stability/criteria.md @@ -0,0 +1,5 @@ +1. A new changeset file is created to document a patch that improves diff editing animations and enhances prompts for large file edits. An indicator showing the number of diff edits is also added next to each file path. +2. In `diff.ts`, the error message thrown when a `SEARCH` block doesn’t match content has been updated to clarify that the mismatch could be due to out-of-order blocks. +3. In `responses.ts`, the assistant response for diff mismatches now recommends limiting to 1–3 `SEARCH/REPLACE` blocks at a time for large files. It also simplifies fallback instructions for using the `write_to_file` tool. +4. The `DiffViewProvider.ts` file has been updated to replace line-by-line animations with chunk-based updates for better performance. For large diffs, a smooth scrolling animation is introduced to maintain visual context. Small diffs still scroll directly. +5. In `CodeAccordian.tsx`, a new visual indicator displays the number of `REPLACE` blocks in the code diff using a diff icon and count, providing quick insight into the volume of changes. diff --git a/crates/eval/examples/checkpoint_stability/prompt.md b/crates/eval/examples/checkpoint_stability/prompt.md new file mode 100644 index 0000000000..4c97e52ca7 --- /dev/null +++ b/crates/eval/examples/checkpoint_stability/prompt.md @@ -0,0 +1,7 @@ +We're trying to improve both performance and usability when working with large diffs in the editor. A few areas need attention: + +First, the current diff animation applies updates line-by-line, which can feel slow and visually jarring for large edits. Could you revise the logic so that we update the editor in larger chunks instead? For smaller diffs, direct scrolling to the edited line is fine, but for larger changes, it would be great to implement a smooth scrolling animation that steps through the affected region before settling at the final line. + +Second, the current error message when a SEARCH block doesn't match is a bit too vague. Let's make it clearer that the issue could be due to out-of-order or imprecise SEARCH/REPLACE blocks, especially when working with multiple blocks. It might also help to add a suggestion that users try only 1–3 changes at a time for large files before retrying. + +Finally, in the file accordion UI, it would be useful to show how many edits a file contains. Could you parse the diff content and display a count of REPLACE blocks next to the file path, maybe with a small icon for clarity? diff --git a/crates/eval/examples/dd_iaptic_mcp_server_integration/base.toml b/crates/eval/examples/dd_iaptic_mcp_server_integration/base.toml new file mode 100644 index 0000000000..dcf989bca8 --- /dev/null +++ b/crates/eval/examples/dd_iaptic_mcp_server_integration/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/punkpeye/awesome-mcp-servers.git" +revision = "5480a9849b01ae8a5c1433d75ad0415975609571" +language_extension = "md" diff --git a/crates/eval/examples/dd_iaptic_mcp_server_integration/criteria.md b/crates/eval/examples/dd_iaptic_mcp_server_integration/criteria.md new file mode 100644 index 0000000000..fa74ab9d9f --- /dev/null +++ b/crates/eval/examples/dd_iaptic_mcp_server_integration/criteria.md @@ -0,0 +1,5 @@ +1. The diff shows changes to `README.md`, specifically adding a new entry to the "Tools and integrations" list. The new entry is for `@iaptic/mcp-server-iaptic`, which provides access to customer purchase and revenue data. +2. The added line includes: + - The GitHub repository URL + - Three emojis: 🎖️ (possibly representing awards or achievements), 📇 (profiles or contacts), and ☁️ (cloud) + - A description of the tool's functionality: "Connect with [iaptic](https://www.iaptic.com) to ask about your Customer Purchases, Transaction data and App Revenue statistics" diff --git a/crates/eval/examples/dd_iaptic_mcp_server_integration/prompt.md b/crates/eval/examples/dd_iaptic_mcp_server_integration/prompt.md new file mode 100644 index 0000000000..cc88ae4c7f --- /dev/null +++ b/crates/eval/examples/dd_iaptic_mcp_server_integration/prompt.md @@ -0,0 +1,3 @@ +Please add a new tool entry to the README.md file's integration list: "@iaptic/mcp-server-iaptic" with GitHub link, described as "Connect with [iaptic](https://www.iaptic.com) to ask about your Customer Purchases, Transaction data and App Revenue statistics", tagged with the following emojis: 🎖️ 📇 ☁️. Place it appropriately in the existing tools section, following the current alphabetical or category-based order. + +Edit the README file with the above, new resource diff --git a/crates/eval/examples/debian_image_builder/base.toml b/crates/eval/examples/debian_image_builder/base.toml new file mode 100644 index 0000000000..0d0fa6e4a1 --- /dev/null +++ b/crates/eval/examples/debian_image_builder/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/avkcode/container-tools.git" +revision = "34137bb453b4d2dd28b08bd80e26bc3105a50ada" +language_extension = "sh" diff --git a/crates/eval/examples/debian_image_builder/criteria.md b/crates/eval/examples/debian_image_builder/criteria.md new file mode 100644 index 0000000000..5c1dd4ccfc --- /dev/null +++ b/crates/eval/examples/debian_image_builder/criteria.md @@ -0,0 +1,4 @@ +1. Changes to the Makefile where the parameter "--keyrign" was corrected to "--keyring" in multiple build targets including debian11, debian11-java, debian11-java-slim, debian11-graal, debian11-graal-slim, debian11-corretto, debian11-java-slim-maven, debian11-java-slim-gradle, debian11-graal-slim-maven, and debian11-graal-slim-gradle. This appears to be a typo fix across all Java-related build configurations in the Makefile. +2. Introduces significant enhancements to the debian/mkimage.sh script, including adding a usage function with detailed documentation, improving error handling for command-line arguments, and fixing the "--keyrign" parameter to "--keyring" to match the Makefile changes. It also adds better validation for required arguments and more descriptive error messages when values are missing. The script now includes comprehensive documentation about its purpose and usage examples. +3. Shows extensive improvements to the script's functionality and robustness, including adding tracing capabilities, better error handling, and more informative logging. It introduces new helper functions like usage(), die(), warn(), and info() for better user feedback. The script now properly checks for required commands (debootstrap, unzip, trivy) and provides installation instructions if they're missing. It also includes better system checks (Linux OS verification, root privileges check, SELinux status) and implements a more reliable way to handle GPG keys by setting up the correct directory structure and permissions before key import. +4. Continues the script improvements with better package management, repository configuration, and container setup. It adds proper apt repository configuration in the target system, implements package installation with retries, and includes Docker-specific optimizations. The script now provides clearer output about installed packages and their sizes. It also includes better cleanup procedures and more informative completion messages with clear instructions on how to load and run the resulting Docker image. The output now includes example commands and proper formatting for better readability. diff --git a/crates/eval/examples/debian_image_builder/prompt.md b/crates/eval/examples/debian_image_builder/prompt.md new file mode 100644 index 0000000000..4e3651c3d1 --- /dev/null +++ b/crates/eval/examples/debian_image_builder/prompt.md @@ -0,0 +1 @@ +I need to make several improvements to our Debian image-building scripts. First, fix the typo in the `Makefile` where `--keyrign` is incorrectly used instead of `--keyring` across all build targets, including the standard Debian image and Java variants like `debian11-java`, `debian11-graal`, and `debian11-corretto`. Second, enhance the `debian/mkimage.sh` script to include proper error handling, usage documentation, and command-line argument validation. The script should check for required tools like `debootstrap`, `unzip`, and `trivy`, and provide installation instructions if they're missing. Improve the GPG key setup by ensuring the `/root/.gnupg` directory is properly configured before importing keys. Add structured logging with timestamps, warnings, and informational messages. Implement better package installation with retries and proper cleanup. Finally, include clear instructions at the end on how to load and run the generated Docker image, with example commands for verification. The script should be robust, well-documented, and fail early with meaningful error messages if system requirements aren't met. diff --git a/crates/eval/examples/docs_restructure/base.toml b/crates/eval/examples/docs_restructure/base.toml new file mode 100644 index 0000000000..c0917ebe5b --- /dev/null +++ b/crates/eval/examples/docs_restructure/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/YuhangSong/Arena-Baselines.git" +revision = "801ed8566110ddc4a6ada0cc70171c636d78dbb8" +language_extension = "py" diff --git a/crates/eval/examples/docs_restructure/criteria.md b/crates/eval/examples/docs_restructure/criteria.md new file mode 100644 index 0000000000..2a30e3657f --- /dev/null +++ b/crates/eval/examples/docs_restructure/criteria.md @@ -0,0 +1,12 @@ +1. README.md Features Section Reorganization +The features section has been reorganized into two subsections ("Baselines" and "Games") with markdown tables added. The previous bullet points were replaced with more structured content including supported/benchmarked status indicators. A new "Visualization" section was added with TensorBoard and port forwarding instructions. +2. Content Relocation and File Restructuring +The Tennis game documentation and action space details were moved from README.md to a new games.md file. The README was cleaned up by removing commented-out content and consolidating documentation sections. YAML config files (Benchmark-2T1P-Discrete.yaml and Test-Pong.yaml) were modified to replace `selfplay_recent_prob` with `playing_policy_load_recent_prob` and adjust population size options. +3. train.py Refactoring +Significant changes to train.py including: +- Renamed `selfplay_recent_prob` parameter to `playing_policy_load_recent_prob` +- Simplified the nested grid search structure by removing unnecessary loops +- Improved policy loading logic with better checkpoint path handling +- Enhanced error handling and logging for policy saving/reloading +- Removed redundant code and improved code organization +- Added more descriptive console output during policy operations diff --git a/crates/eval/examples/docs_restructure/prompt.md b/crates/eval/examples/docs_restructure/prompt.md new file mode 100644 index 0000000000..08c5c793e8 --- /dev/null +++ b/crates/eval/examples/docs_restructure/prompt.md @@ -0,0 +1,13 @@ +I need to refactor the multi-agent configuration system in our Arena-Baselines repository. The current policy_assignment parameter (self_play, independent) is too coarse. I want to replace it with a more flexible set of parameters to better support advanced training schemes like population-based training (PBT) and sophisticated self-play with historical opponents. + +Specifically, I will introduce four new configuration parameters: + +iterations_per_reload: Controls the frequency (in training iterations) at which policies are saved and potentially reloaded. +num_learning_policies: Explicitly defines how many agents use policies that are actively being trained (can be an integer or 'all'). +selfplay_recent_prob: For non-learning agents (players), this determines the probability of loading the latest version of a learning policy versus loading a uniformly random historical version during reloads. +size_population: Specifies the number of distinct policy versions maintained for each learning agent, enabling PBT-style experiments. +To implement this, I will significantly modify train.py. This includes updating the argument parser, changing how experiment configurations are expanded (especially with grid_search), and implementing a new callback function (on_train_result). This callback will handle the periodic saving (using pickle) of learning policies to structured directories and the reloading of all policies (learning and playing) according to the new parameters (iterations_per_reload, selfplay_recent_prob, size_population). Playing policies will use deterministic actions. + +I'll also reorganize the codebase by renaming arena/rllib_env.py to arena/arena.py and creating a new arena/utils.py file to house utility functions (like configuration helpers, ID generators, DeterministicCategorical) and constants. + +Finally, I will update the example configuration files (Benchmark-2T1P-Discrete.yaml, Test-Pong.yaml) to remove policy_assignment and demonstrate the usage of the new parameters, including within grid_search. diff --git a/crates/eval/examples/expand_laravel_php_support/base.toml b/crates/eval/examples/expand_laravel_php_support/base.toml new file mode 100644 index 0000000000..175c1fca4d --- /dev/null +++ b/crates/eval/examples/expand_laravel_php_support/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/calebporzio/sushi.git" +revision = "01dd34fe3374f5fb7ce63756c0419385e31cd532" +language_extension = "php" diff --git a/crates/eval/examples/expand_laravel_php_support/criteria.md b/crates/eval/examples/expand_laravel_php_support/criteria.md new file mode 100644 index 0000000000..e9ccd7a7d9 --- /dev/null +++ b/crates/eval/examples/expand_laravel_php_support/criteria.md @@ -0,0 +1,3 @@ +1. The GitHub workflow file has been significantly updated to expand testing coverage and improve the CI process. The changes introduce a new `fail-fast: false` setting to allow all matrix combinations to complete even if some fail. The testing matrix now includes PHP 8.4 and Laravel 12.* alongside the existing versions. The configuration includes specific testbench version mappings for Laravel 12.* and removes the DBAL requirement for Laravel 11.* tests. Numerous new test combinations have been added across all Laravel versions to include PHP 8.4 testing. The dependency installation process has been restructured into separate steps - one specifically for DBAL when needed, and another for general dependencies using updated composer commands with precise version constraints. +2. The composer.json file has been updated to support the newly added Laravel 12.* version in both the main requirements and development dependencies. The testbench package now explicitly includes versions 5.* and 10.* in its supported range. For testing tools, PHPUnit 11.* has been added to the list of supported versions while maintaining backward compatibility with older versions. These changes ensure the package can be used with the latest Laravel ecosystem components while preserving compatibility with existing installations. +st file modifications primarily focus on adapting to changes in Laravel 11+ where column type handling was updated. The changes introduce version-aware assertions that check whether to expect 'string' or 'varchar' as column types based on the Laravel version being tested. A new import for the version comparison function was added to support these conditional checks. Additional safeguards were implemented, including a check for the HandlesAnnotations trait before running database migration tests, making the test suite more robust when running in different environments. The column type assertions in multiple test methods were updated to use these version-aware checks to maintain compatibility across Laravel versions. diff --git a/crates/eval/examples/expand_laravel_php_support/prompt.md b/crates/eval/examples/expand_laravel_php_support/prompt.md new file mode 100644 index 0000000000..e193cdb3c6 --- /dev/null +++ b/crates/eval/examples/expand_laravel_php_support/prompt.md @@ -0,0 +1,11 @@ + +I'd like to update our Laravel package's CI workflow and dependencies to ensure compatibility with the upcoming Laravel 12 release and PHP 8.4. Currently, our package supports Laravel versions 5.8 through 11 and PHP versions 7.1 through 8.3, and we'll need to extend this support while maintaining backward compatibility. + +**Key Changes Needed:** +First, we'll need to update composer.json to explicitly support Laravel 12. The CI test matrix should also be expanded to include PHP 8.4 testing across all supported Laravel versions. The workflow configuration will require adjustments to properly handle these new version combinations. + +There are some test compatibility issues we'll need to address - particularly around how we check string column types in Laravel 11+ (where 'string' was changed to 'varchar'), and we should add conditional skipping for tests that depend on traits that might not be available in all test environments. + +While making these changes, we could also implement some workflow improvements: enabling the fail-fast: false option to get complete test results even with individual failures, modernizing our dependency installation approach using the newer composer update syntax, and making the DBAL dependency installation conditional since it's not needed for all test cases. + +Would you be able to help review these changes or suggest any additional considerations we should keep in mind for this compatibility update? I want to make sure we maintain stability while expanding our support coverage. diff --git a/crates/eval/examples/finnish_translation/base.toml b/crates/eval/examples/finnish_translation/base.toml new file mode 100644 index 0000000000..a54cbb4626 --- /dev/null +++ b/crates/eval/examples/finnish_translation/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/sdras/array-explorer.git" +revision = "8ff1a72f7ba24d44946bf591c3586b0dcccc2381" +language_extension = "js" diff --git a/crates/eval/examples/finnish_translation/criteria.md b/crates/eval/examples/finnish_translation/criteria.md new file mode 100644 index 0000000000..356aee78e0 --- /dev/null +++ b/crates/eval/examples/finnish_translation/criteria.md @@ -0,0 +1,12 @@ +1. **EditorConfig Change** +Added a new setting `quote_type = single` to the `.editorconfig` file. This specifies that single quotes should be used for quoting in the codebase. +2. **New Finnish Locale Files** +Added two new Finnish language files: + - `src/locale/fi/index.js`: Contains Finnish translations for UI strings and method descriptions + - `store/fi/index.js`: Contains Finnish translations for all array method documentation (298 lines) + - `store/fi/meta.json`: Metadata about the Finnish translation (language code "fi", full name "Finnish", created by "sjarva") +3. **Store Integration Updates** +Modified `store/index.js` to: + - Import the new Finnish locale files (`import fi from './fi/index'` and `import translationsFi from '../src/locale/fi/index'`) + - Add Finnish to the Vuex store state (`fi`) + - Register Finnish translations with Vue I18n (`Vue.i18n.add('fi', translationsFi)`) diff --git a/crates/eval/examples/finnish_translation/prompt.md b/crates/eval/examples/finnish_translation/prompt.md new file mode 100644 index 0000000000..d4782f41ea --- /dev/null +++ b/crates/eval/examples/finnish_translation/prompt.md @@ -0,0 +1,5 @@ +I’m working on adding Finnish (fi) language support to our array method reference application, which helps users determine the right JavaScript array methods based on their needs. To achieve this, I’ll need to: + +First, create the Finnish locale file containing translations for method selection options, method types (such as add, remove, find, and iterate), and primary action choices. Next, I’ll add Finnish translations to the store, covering all array methods (like splice, push, and unshift), including detailed descriptions of their behaviors, parameters, return values, and example code with outputs. + +Additionally, I’ll generate a Finnish meta file with language metadata (language code, full name, and contributor info). Finally, I’ll update the main store index to integrate Finnish alongside existing languages like English, Spanish, and German. diff --git a/crates/eval/examples/language_model_file_support/base.toml b/crates/eval/examples/language_model_file_support/base.toml new file mode 100644 index 0000000000..5fb211f3af --- /dev/null +++ b/crates/eval/examples/language_model_file_support/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/vercel/ai.git" +revision = "1766edec300deb05c84bb7fefc034af4c2bc1165" +language_extension = "ts" diff --git a/crates/eval/examples/language_model_file_support/criteria.md b/crates/eval/examples/language_model_file_support/criteria.md new file mode 100644 index 0000000000..0f2f6ba492 --- /dev/null +++ b/crates/eval/examples/language_model_file_support/criteria.md @@ -0,0 +1,3 @@ +1. Introduces a new changeset file that documents a patch for the '@ai-sdk/provider' package. The changeset indicates a chore task where 'LanguageModelV2File' is being extracted, suggesting a refactoring effort to modularize the codebase by separating file-related types into their own module. +2. Modifications to the language model v2 index file where a new export statement for 'language-model-v2-file' has been added. This change reflects the extraction mentioned in the changeset and makes the new file type available to other parts of the application. Additionally, there are significant changes to the language model v2 implementation file where the inline file type definition has been replaced with the newly extracted 'LanguageModelV2File' type, both in the main model interface and in the stream part union type, demonstrating the consolidation of file-related types into a single, reusable definition. +3. Present the newly created 'language-model-v2-file.ts' file which defines the 'LanguageModelV2File' type with comprehensive documentation. The type includes two properties: 'mediaType' which specifies the IANA media type of the file with a reference to the official media types registry, and 'data' which can be either a base64 encoded string or binary data, with clear documentation about maintaining the original format from the API without unnecessary conversion. This new file represents the extracted type that is now being used throughout the codebase. diff --git a/crates/eval/examples/language_model_file_support/prompt.md b/crates/eval/examples/language_model_file_support/prompt.md new file mode 100644 index 0000000000..71c5b9fba4 --- /dev/null +++ b/crates/eval/examples/language_model_file_support/prompt.md @@ -0,0 +1 @@ +We need to improve how our language model handles file attachments by making the file type definitions more modular and reusable. Currently, file-related properties are defined inline within the model’s response and stream types, which makes maintenance harder and duplicates documentation. The goal is to extract these definitions into a dedicated type that can be shared consistently across both static responses and streaming payloads. The new type should include clear documentation about media types (referencing IANA standards) and support both base64 and binary data formats without unnecessary conversions. This change should maintain backward compatibility while centralizing the file structure definition for better type safety and readability. Focus on clean separation of concerns, and ensure the extracted type is properly exported and imported where needed. diff --git a/crates/eval/examples/license_management/base.toml b/crates/eval/examples/license_management/base.toml new file mode 100644 index 0000000000..cb63ccc048 --- /dev/null +++ b/crates/eval/examples/license_management/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/SAP-samples/abap-cheat-sheets.git" +revision = "262c0472eeb03e05ff8235767356a328d97850e6" +require_lsp = false diff --git a/crates/eval/examples/license_management/criteria.md b/crates/eval/examples/license_management/criteria.md new file mode 100644 index 0000000000..ad270f4ccf --- /dev/null +++ b/crates/eval/examples/license_management/criteria.md @@ -0,0 +1,3 @@ +1. The file `.reuse/dep5` has been deleted. This file previously contained copyright and licensing information in Debian's copyright format, including details about API usage with SAP products, copyright notice (2022 SAP SE or affiliates), and Apache-2.0 license information. +2. A new file `REUSE.toml` has been created with similar copyright and licensing information but in a different format. It includes the package name, supplier information, download location, and the same detailed disclaimer about API usage with SAP products that was in the deleted file. +3. The new `REUSE.toml` file also contains annotations specifying that the copyright text and Apache-2.0 license apply to all files (`path = "**"`) with aggregate precedence, effectively maintaining the same licensing terms but in a different configuration format. diff --git a/crates/eval/examples/license_management/prompt.md b/crates/eval/examples/license_management/prompt.md new file mode 100644 index 0000000000..df6901fc16 --- /dev/null +++ b/crates/eval/examples/license_management/prompt.md @@ -0,0 +1,17 @@ +I need to switch our license stuff from the old .reuse/dep5 file to the new REUSE.toml format. basically same info, just different format. here's what's in the old file: + +project name: abap-cheat-sheets +contact: daniel reger's email +repo link +that long SAP API disclaimer +copyright: SAP + contributors, 2022 +license: Apache-2.0 +need to: + +delete the old .reuse/dep5 file +make a new REUSE.toml with: +same project info (name, contact, repo) +same exact API disclaimer text +SPDX-style copyright & license fields +apply to all files (** glob) with aggregate precedence +not changing any actual license terms, just updating the format. can you give me the exact REUSE.toml file we need? diff --git a/crates/eval/examples/metal_i64_support/base.toml b/crates/eval/examples/metal_i64_support/base.toml new file mode 100644 index 0000000000..01b0703231 --- /dev/null +++ b/crates/eval/examples/metal_i64_support/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/huggingface/candle.git" +revision = "3164a19a5dc18f5e0f7a063ae85a0cfd289e98f1" +language_extension = "rs" diff --git a/crates/eval/examples/metal_i64_support/criteria.md b/crates/eval/examples/metal_i64_support/criteria.md new file mode 100644 index 0000000000..35741151c9 --- /dev/null +++ b/crates/eval/examples/metal_i64_support/criteria.md @@ -0,0 +1,4 @@ +1. The changes improve the configurability of the `TextGeneration` struct and its initialization by refactoring generation parameters (`temperature`, `top_p`) to use non-optional types with default values, simplifying their use throughout the codebase. +2. The argument parser is updated to enhance usability: `verbose_prompt` is renamed to a more general `verbose` flag, several arguments are given default values (e.g., `temperature`, `top_p`, `sample_len`), and optional arguments like `cache_path` and `weight_path` are now properly handled with conditional logic and fallbacks. +3. The code loading the model configuration is updated to support deserializing from a JSON config file using Serde, and the `Config` struct is extended with a new `rope_ratio` field with a default value via a helper function, improving flexibility for different model setups. +4. Import statements and general code layout are cleaned up for clarity and consistency, including reorganizing imports and removing unnecessary unwraps or panics, while maintaining the same core functionality of the text generation pipeline. diff --git a/crates/eval/examples/metal_i64_support/prompt.md b/crates/eval/examples/metal_i64_support/prompt.md new file mode 100644 index 0000000000..bdc365b1cd --- /dev/null +++ b/crates/eval/examples/metal_i64_support/prompt.md @@ -0,0 +1 @@ +I'd like to improve the configurability and usability of the text generation script for the CodeGeeX4-9B model. Please refactor the argument parsing to set more user-friendly defaults where possible, especially for generation parameters like temperature and top-p, and change fields like verbose_prompt to a more general verbose flag. Simplify the handling of optional paths like cache or weight paths, making them truly optional with fallbacks. I also want the model config to support deserialization from a JSON file instead of relying on hardcoded defaults, including support for a rope_ratio parameter with a sensible default. Lastly, please clean up the code for consistency—such as import ordering—and ensure everything aligns with these improvements without changing the overall functionality. diff --git a/crates/eval/examples/nan_diff_handling/base.toml b/crates/eval/examples/nan_diff_handling/base.toml new file mode 100644 index 0000000000..7a046a28f4 --- /dev/null +++ b/crates/eval/examples/nan_diff_handling/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/AsyncBanana/microdiff" +revision = "ce2055948483d01fb1e96def4ab98d6339d3b2f9" +language_extension = "js" diff --git a/crates/eval/examples/nan_diff_handling/criteria.md b/crates/eval/examples/nan_diff_handling/criteria.md new file mode 100644 index 0000000000..abb19b15bc --- /dev/null +++ b/crates/eval/examples/nan_diff_handling/criteria.md @@ -0,0 +1,6 @@ +1. **NaN Comparison Logic Update**: +The diff modifies the comparison function to explicitly handle NaN values as equivalent. Previously, the function relied on string conversion for NaN comparison, but now it first checks if both values are NaN using Number.isNaN() before proceeding with other comparison logic. This change ensures consistent behavior when comparing NaN values in objects. +2. **New NaN Test Suite - Object Operations**: +A comprehensive test suite is added to verify NaN handling in object operations. The tests cover: creating new objects with NaN values, changing NaN values to other numbers, verifying no changes when NaN values remain the same, and removing properties with NaN values. Each test case validates the diff output structure and type of operation. +3. **New NaN Test Suite - Array Operations**: +The test suite extends to array operations with similar test cases as objects but adapted for array contexts. It tests: adding NaN to arrays, replacing NaN with other numbers, maintaining arrays with unchanged NaN values, and removing NaN elements from arrays. The tests ensure consistent behavior between object and array operations involving NaN values. diff --git a/crates/eval/examples/nan_diff_handling/prompt.md b/crates/eval/examples/nan_diff_handling/prompt.md new file mode 100644 index 0000000000..79e362c69a --- /dev/null +++ b/crates/eval/examples/nan_diff_handling/prompt.md @@ -0,0 +1 @@ +The goal of this update is to fix NaN value handling in our JavaScript object diffing functionality. Currently, the diff function fails to properly recognize that two NaN values should be treated as equal due to JavaScript's native behavior where `NaN !== NaN`. This causes incorrect change detection when comparing objects or arrays containing NaN values. The solution involves modifying the diff function to explicitly check for NaN values using `Number.isNaN()` during comparisons of object keys and values, ensuring NaN values are treated as equivalent. The implementation requires adding specific NaN equivalence checks while maintaining existing comparison logic. Additionally, comprehensive unit tests are being added to verify correct handling across various scenarios: creating objects/arrays with NaN values, changing NaN values to other values, ensuring no false positives when NaN values remain unchanged, and properly tracking removal of NaN values from both objects and arrays. This change will bring the diff behavior in line with mathematical expectations for NaN comparisons while maintaining all other existing functionality. diff --git a/crates/eval/examples/optimizer_schema_refactor/base.toml b/crates/eval/examples/optimizer_schema_refactor/base.toml new file mode 100644 index 0000000000..b29a97f5e8 --- /dev/null +++ b/crates/eval/examples/optimizer_schema_refactor/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/redis/redis-vl-python.git" +revision = "494e5e2f8cf800b90c7383385095c2e503404bc5" +language_extension = "py" diff --git a/crates/eval/examples/optimizer_schema_refactor/criteria.md b/crates/eval/examples/optimizer_schema_refactor/criteria.md new file mode 100644 index 0000000000..97e7c2b8ad --- /dev/null +++ b/crates/eval/examples/optimizer_schema_refactor/criteria.md @@ -0,0 +1,3 @@ +1. The changes involve renaming the `TestData` class to `LabeledData` across multiple files. This includes updating the import statements in `__init__.py`, `cache.py`, `router.py`, `schema.py`, and `utils.py` to reflect this new class name. The `__all__` list in `__init__.py` is also updated to export `LabeledData` instead of `TestData`. This appears to be a conceptual renaming to better reflect the purpose of the data structure. +2. The modifications update all function signatures and type hints that previously used `TestData` to now use `LabeledData`. This affects several functions in `cache.py` including `_generate_run_cache`, `_eval_cache`, and `_grid_search_opt_cache`, as well as functions in `router.py` like `_generate_run_router` and `_eval_router`. The utility functions in `utils.py` are also updated to work with `LabeledData` instead of `TestData`. +3. The changes introduce a new `search_step` parameter in the router optimization logic within `router.py`, with a default value of 0.10. This parameter is passed through to the `_router_random_search` function and is used in the optimization process. The test file `test_threshold_optimizer.py` is updated to explicitly set this parameter to 0.5 when calling the optimize method, demonstrating how it can be configured for different search granularities during threshold optimization. diff --git a/crates/eval/examples/optimizer_schema_refactor/prompt.md b/crates/eval/examples/optimizer_schema_refactor/prompt.md new file mode 100644 index 0000000000..4a4635d1e9 --- /dev/null +++ b/crates/eval/examples/optimizer_schema_refactor/prompt.md @@ -0,0 +1 @@ +I need to refactor our codebase to improve the clarity and consistency of our data model, particularly around how we handle labeled evaluation data for our threshold optimization system. Currently, the naming and structure might imply that this data is only used for testing, when in reality it represents labeled examples that power both training and evaluation. The changes should better reflect that these are curated data points with known outcomes, not just test cases. Focus on updating the core data model and ensuring all dependent components—like the cache optimizer, router, and evaluation utilities—properly reference this updated concept. The implementation should maintain all existing functionality while making the naming more semantically accurate. Where relevant, consider adding parameters to fine-tune optimization behavior, like allowing control over the granularity of threshold searches. diff --git a/crates/eval/examples/rate_limit_endpoints/base.toml b/crates/eval/examples/rate_limit_endpoints/base.toml new file mode 100644 index 0000000000..0a3437f288 --- /dev/null +++ b/crates/eval/examples/rate_limit_endpoints/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/matryer/goblueprints.git" +revision = "68041a598865cc3f4fa2acd4119081a2ea0826bf" +language_extension = "go" diff --git a/crates/eval/examples/rate_limit_endpoints/criteria.md b/crates/eval/examples/rate_limit_endpoints/criteria.md new file mode 100644 index 0000000000..feebae4439 --- /dev/null +++ b/crates/eval/examples/rate_limit_endpoints/criteria.md @@ -0,0 +1,12 @@ +1. The main.go changes introduce rate-limited endpoints by creating them via `MakeEndpoints` and passing them to both HTTP and gRPC servers instead of directly using the service. This includes: + - Adding endpoint creation before server startup + - Modifying HTTP server to use endpoints + - Modifying gRPC server to use endpoints +2. The server_grpc.go changes update the gRPC server implementation to use the provided endpoints instead of creating them internally. This affects both hash and validate endpoints which are now taken from the Endpoints struct rather than being created via makeHashEndpoint/makeValidateEndpoint. +3. The server_http.go changes mirror the gRPC server changes, modifying the HTTP server to use endpoints from the Endpoints struct rather than creating them internally for both hash and validate routes. +4. The service.go changes include: + - Renaming makeHashEndpoint to MakeHashEndpoint and making it public + - Renaming makeValidateEndpoint to MakeValidateEndpoint and making it public + - Adding new MakeEndpoints function that creates rate-limited endpoints using a token bucket (5 requests per second) + - Adding new dependencies for rate limiting (kitrl and ratelimit packages) + - The Endpoints struct remains the same but is now populated with rate-limited versions of the endpoints diff --git a/crates/eval/examples/rate_limit_endpoints/prompt.md b/crates/eval/examples/rate_limit_endpoints/prompt.md new file mode 100644 index 0000000000..91416ed7ad --- /dev/null +++ b/crates/eval/examples/rate_limit_endpoints/prompt.md @@ -0,0 +1,18 @@ +Here’s a more abstract, goal-oriented version of your request without diving into implementation specifics: + +--- + +### **Request: Add Rate Limiting to Vault Service** + +We need to introduce rate limiting to our vault service to protect it from excessive traffic and ensure fair usage. The service currently handles password hashing and validation through both HTTP and gRPC, and we want to enforce a controlled request rate across all endpoints. + +#### **Key Requirements:** +- Apply a global rate limit (e.g., 5 requests per second) to prevent abuse. +- Ensure the rate limiting works consistently across both HTTP and gRPC interfaces. +- Refactor the service to cleanly support rate limiting without breaking existing functionality. +- Maintain flexibility so that limits can be adjusted if needed. + +#### **Implementation Approach (High-Level):** +- Use a token bucket or similar algorithm for smooth rate limiting. +- Integrate with our existing middleware/request pipeline. +- Keep the changes minimal but scalable for future adjustments. diff --git a/crates/eval/examples/request_to_axios_migration/base.toml b/crates/eval/examples/request_to_axios_migration/base.toml new file mode 100644 index 0000000000..85fdcd1569 --- /dev/null +++ b/crates/eval/examples/request_to_axios_migration/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/localtunnel/localtunnel.git" +revision = "4c136a265c2005bcb81bf47709c8ca9b634f2fc1" +language_extension = "js" diff --git a/crates/eval/examples/request_to_axios_migration/criteria.md b/crates/eval/examples/request_to_axios_migration/criteria.md new file mode 100644 index 0000000000..a7c09ce84c --- /dev/null +++ b/crates/eval/examples/request_to_axios_migration/criteria.md @@ -0,0 +1,3 @@ +1. The first change replaces the `request` module import with `axios` in Tunnel.js. This is accompanied by modifications to the request parameters where `path` and `json` fields are removed and replaced with `responseType: 'json'`. The request URI construction is also slightly modified to separate the base URI from the parameters. +2. The second chunk shows significant changes to the request handling logic in Tunnel.js. The callback-based `request` implementation is replaced with a promise-based `axios.get` approach. The error handling is restructured to use `.catch()` instead of checking for errors in the callback. The success case now extracts data from `res.data` instead of directly from the response body, and the status code check looks at `res.status` instead of `res.statusCode`. +3. The third chunk shows changes to package.json where the `request` dependency is removed and replaced with `axios` at version 0.17.1. The dependencies are also reordered, with `debug` and `openurl` moved up and `yargs` moved to the end of the list, though their versions remain unchanged. The devDependencies section remains untouched. diff --git a/crates/eval/examples/request_to_axios_migration/prompt.md b/crates/eval/examples/request_to_axios_migration/prompt.md new file mode 100644 index 0000000000..c5408efee6 --- /dev/null +++ b/crates/eval/examples/request_to_axios_migration/prompt.md @@ -0,0 +1 @@ +I need help modernizing the HTTP client in my Node.js tunneling service. The current implementation uses the older `request` library, which is now deprecated, and I'd like to switch to a more modern, promise-based alternative like `axios`. The changes should maintain all existing functionality—including error handling, retry logic, and response parsing—but improve readability and maintainability by using async/await or proper promise chaining where possible. The request parameters and response handling should be updated to match the new library's conventions while preserving the same behavior for downstream consumers. Additionally, ensure the package.json dependencies are updated accordingly, removing deprecated packages and cleaning up the dependency list. The core tunneling logic should remain unchanged; this is purely about updating the HTTP client layer to be more future-proof. diff --git a/crates/eval/examples/runtime_script_refactor/base.toml b/crates/eval/examples/runtime_script_refactor/base.toml new file mode 100644 index 0000000000..f354196301 --- /dev/null +++ b/crates/eval/examples/runtime_script_refactor/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/thalissonvs/pydoll.git" +revision = "9ea9e91c716b60a7cc8f11ecd865093d460f31aa" +language_extension = "py" diff --git a/crates/eval/examples/runtime_script_refactor/criteria.md b/crates/eval/examples/runtime_script_refactor/criteria.md new file mode 100644 index 0000000000..bc7d77ef58 --- /dev/null +++ b/crates/eval/examples/runtime_script_refactor/criteria.md @@ -0,0 +1,6 @@ +1. **Added RuntimeCommands import and WebElement to page.py** +The changes add an import for `RuntimeCommands` and `WebElement` to `page.py`. The `execute_js_script` method is renamed to `execute_script` and enhanced to support execution in the context of a WebElement. The method now uses `RuntimeCommands` for script evaluation. +2. **Refactored Runtime-related commands from DomCommands to new RuntimeCommands class** +The changes move all Runtime-related command templates and methods from `DomCommands` in `dom.py` to a new `runtime.py` file. This includes `EVALUATE_TEMPLATE`, `CALL_FUNCTION_ON_TEMPLATE`, `GET_PROPERTIES`, and their associated methods. The DomCommands class now uses RuntimeCommands for JavaScript evaluation. +3. **Added Scripts constants and enhanced WebElement functionality** +The changes add a new `Scripts` class to `constants.py` containing JavaScript snippets for common operations. The `element.py` file is significantly enhanced with new methods for script execution, visibility checking, and improved click handling. New exceptions are added to `exceptions.py` for better error handling. diff --git a/crates/eval/examples/runtime_script_refactor/prompt.md b/crates/eval/examples/runtime_script_refactor/prompt.md new file mode 100644 index 0000000000..1c1bfeb6ee --- /dev/null +++ b/crates/eval/examples/runtime_script_refactor/prompt.md @@ -0,0 +1,7 @@ +I'm looking to improve our Python web automation library (pydoll) to make it more robust and maintainable, particularly around JavaScript execution and element interactions. Currently, we need to better organize our Runtime-related commands and enhance how scripts are executed in the browser context. + +The main focus areas include creating a dedicated RuntimeCommands class to centralize all JavaScript-related operations, moving these functions out of DomCommands for cleaner separation of concerns. This new class would handle script evaluation, function calling, and property lookups. We should also enhance the existing page.execute_js_script method—renaming it to execute_script for clarity—and expand its functionality to support execution within specific WebElement contexts, including passing elements as arguments. + +For element interactions, we need more reliable mechanisms, particularly around clicking elements. The improvements would include visibility checks, verifying elements aren't obscured, and implementing proper error handling with descriptive exceptions when interactions fail. The current click implementation should be moved to realistic_click, while the new click method would incorporate these safety checks. Additionally, we should consolidate commonly used JavaScript snippets into a centralized Scripts class for better maintainability. + +The overall goal is to strengthen the library's reliability for automation tasks while making the codebase more organized and easier to maintain. These changes will provide better error handling, clearer structure, and more intuitive APIs for working with page elements and JavaScript execution. Would you be able to help break this down into actionable steps or suggest any improvements to this approach? diff --git a/crates/eval/examples/standardized_docker_dependency_checks/base.toml b/crates/eval/examples/standardized_docker_dependency_checks/base.toml new file mode 100644 index 0000000000..ccd751d5b5 --- /dev/null +++ b/crates/eval/examples/standardized_docker_dependency_checks/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/basecamp/kamal.git" +revision = "0174b872bfc34b66852cffb58514ae079f21d299" +language_extension = "rb" diff --git a/crates/eval/examples/standardized_docker_dependency_checks/criteria.md b/crates/eval/examples/standardized_docker_dependency_checks/criteria.md new file mode 100644 index 0000000000..c8526dab5c --- /dev/null +++ b/crates/eval/examples/standardized_docker_dependency_checks/criteria.md @@ -0,0 +1,7 @@ +1. The changes introduce a new `DependencyError` class in `kamal/cli.rb` alongside other error classes like `BootError` and `HookError`. This new error class will be used to handle dependency-related failures. +2. In `kamal/cli/base.rb`, a new method `ensure_docker_installed` is added which checks for Docker and buildx plugin installation locally. It raises the new `DependencyError` with appropriate messages if either Docker or buildx plugin are not found, replacing similar functionality that was previously scattered elsewhere. +3. The `kamal/cli/build.rb` file is modified to use the new `ensure_docker_installed` method instead of the removed `verify_local_dependencies` method. The error handling is now consistent, using `DependencyError` instead of `BuildError` for dependency-related failures. +4. The `kamal/cli/registry.rb` file now includes a call to `ensure_docker_installed` at the start of the login method, ensuring Docker is available before attempting registry operations. +5. The `kamal/commands/base.rb` file adds a new public method `ensure_docker_installed` that combines checks for both Docker and buildx plugin installation, moving this functionality from the Builder class. +6. The `kamal/commands/builder.rb` file is simplified by removing the `ensure_local_dependencies_installed` method and related private methods, as this functionality has been moved to the base commands class. +7. Test files are updated to reflect these changes, with `build_test.rb` now expecting `DependencyError` instead of `BuildError` for dependency failures, and `registry_test.rb` adding a new test case for Docker dependency checking during login. diff --git a/crates/eval/examples/standardized_docker_dependency_checks/prompt.md b/crates/eval/examples/standardized_docker_dependency_checks/prompt.md new file mode 100644 index 0000000000..b2b13cf579 --- /dev/null +++ b/crates/eval/examples/standardized_docker_dependency_checks/prompt.md @@ -0,0 +1 @@ +I need to improve how our codebase handles Docker dependency checks and error reporting. Right now, the logic for verifying Docker and buildx installations is scattered across different classes, and the error messages aren't consistent. I'd like a more unified approach where we centralize these checks in a single place, making it easier to maintain and reuse. Additionally, we should introduce a dedicated error type for dependency-related failures instead of repurposing existing errors like BuildError. The changes should ensure that any command requiring Docker (like builds or registry logins) properly validates dependencies first, with clear error messages if something is missing. The solution should be clean, follow existing patterns in the codebase, and include any necessary test updates to reflect the new behavior. diff --git a/crates/eval/examples/table_metrics_sorting/base.toml b/crates/eval/examples/table_metrics_sorting/base.toml new file mode 100644 index 0000000000..ef915651e1 --- /dev/null +++ b/crates/eval/examples/table_metrics_sorting/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/duyet/clickhouse-monitoring.git" +revision = "b8ab1a957115f41c916e7061b432ae00b1bbe7db" +language_extension = "ts" diff --git a/crates/eval/examples/table_metrics_sorting/criteria.md b/crates/eval/examples/table_metrics_sorting/criteria.md new file mode 100644 index 0000000000..8a595402eb --- /dev/null +++ b/crates/eval/examples/table_metrics_sorting/criteria.md @@ -0,0 +1,5 @@ +1. The SQL query in tables-overview.ts has been enhanced to include additional metrics for part sizes, both average and maximum. New fields have been added for compressed and uncompressed average part sizes with their readable formats and percentage calculations. Similarly, maximum part size metrics have been added with the same set of calculations. These additions provide more granular visibility into table partition characteristics while maintaining the existing percentage calculations relative to the maximum values across all tables. +2. The column ordering and formatting in tables-overview.ts has been updated to accommodate the new part size metrics. The new readable_avg_part_size and readable_max_part_size columns have been added to the columns array and configured with BackgroundBar formatting. The engine column has been moved to the end of the list for better grouping of related metrics. The sortingFns configuration has been added to specify custom sorting behavior for various compressed and uncompressed size columns. +3. The column definitions system has been enhanced to support custom sorting functions. A new sorting-fns.ts file has been created containing a sort_column_using_actual_value function that enables sorting based on underlying numeric values rather than formatted strings. The getColumnDefs function now checks for both custom and built-in sorting functions in the config and applies them appropriately to column definitions. +4. The data table component has been updated to include custom sorting functions in its configuration. The getCustomSortingFns function is now passed to the table's sortingFns option, making these functions available for all columns. The ValueOf utility type has been added to generic.ts to support proper typing of the sorting functions. +5. The query config type has been extended to include a new optional sortingFns property. This property allows specifying custom sorting functions for specific columns in the table configuration. The type imports have been reorganized, and CustomSortingFnNames is now properly imported and used in the QueryConfig interface. diff --git a/crates/eval/examples/table_metrics_sorting/prompt.md b/crates/eval/examples/table_metrics_sorting/prompt.md new file mode 100644 index 0000000000..903c4ad001 --- /dev/null +++ b/crates/eval/examples/table_metrics_sorting/prompt.md @@ -0,0 +1 @@ +I need to enhance our data table functionality to support more advanced sorting capabilities, particularly for columns that display formatted values (like readable sizes or percentages) but should sort based on their underlying raw numeric values. The table should also include additional metrics for average and maximum part sizes (both compressed and uncompressed) to give better insights into table storage characteristics. These new metrics should follow the same pattern as existing columns, with formatted readable versions, percentage calculations relative to the dataset maximum, and proper sorting behavior. The sorting system should be flexible enough to support both custom sorting logic (like comparing raw numbers behind formatted strings) and built-in sorting methods, with a clean way to configure which columns use which sorting approach. The implementation should maintain consistency with our existing column formatting system and integrate smoothly with the React Table setup we already have in place. diff --git a/crates/eval/examples/tax_id_validation/base.toml b/crates/eval/examples/tax_id_validation/base.toml new file mode 100644 index 0000000000..a3cff0bbbf --- /dev/null +++ b/crates/eval/examples/tax_id_validation/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/go-playground/validator.git" +revision = "4676b8e43bb907ef07f3bcc4ae2a218b05d60397" +language_extension = "go" diff --git a/crates/eval/examples/tax_id_validation/criteria.md b/crates/eval/examples/tax_id_validation/criteria.md new file mode 100644 index 0000000000..3b26ca1812 --- /dev/null +++ b/crates/eval/examples/tax_id_validation/criteria.md @@ -0,0 +1,3 @@ +1. Documentation updates in README.md, where a new validation type for Employer Identification Numbers (EIN) was added to the supported validators table. This addition was carefully positioned between the existing "e164" phone number format and "email" validators to maintain alphabetical ordering. The entry follows the established table format with pipe-separated columns and includes a clear description indicating its purpose for validating U.S. Employer Identification Numbers. Notably, this change was made without modifying any of the existing documentation entries, preserving all current validator descriptions while expanding the supported validation types. +2. Core implementation of the EIN validation across multiple files. In baked_in.go, this involved adding an "ein" entry to the validator map that points to a newly created isEIN function, following the same pattern as other validator registrations. The isEIN() function itself implements the validation logic, checking for both length requirements (exactly 10 characters) and pattern matching using a new regular expression. The regexes.go file was updated with a new einRegexString constant defining the EIN pattern (##-#######) and corresponding regex variable initialization, utilizing the existing lazyRegexCompile helper function for consistency. Documentation was added in doc.go following the established format for validator descriptions, complete with a simple usage example. Throughout these changes, careful attention was paid to maintain consistent error handling patterns and code organization while removing unnecessary newlines in several functions to improve readability. +3. Testing improvements and code quality enhancements, primarily in validator_test.go. A comprehensive TestEINStringValidation test case was added, covering various valid and invalid EIN formats, including tests for length requirements and hyphen positioning. This new test follows the same structure and assertion patterns as existing validation tests. Numerous code quality improvements were made throughout the test file, including grouping interface declarations, fixing comment formatting, removing unnecessary newlines in struct declarations, correcting indentation in test cases, and adding missing newlines between tests. These changes significantly improved code readability while maintaining all existing test logic and ensuring backward compatibility. The improvements demonstrate careful attention to maintaining consistent patterns throughout the test suite while adding thorough test coverage for the new EIN validation functionality. diff --git a/crates/eval/examples/tax_id_validation/prompt.md b/crates/eval/examples/tax_id_validation/prompt.md new file mode 100644 index 0000000000..e1a8ed4d5f --- /dev/null +++ b/crates/eval/examples/tax_id_validation/prompt.md @@ -0,0 +1,10 @@ + +Add validation support for Employer Identification Numbers (EIN) to the Go validator library + +I need to implement a new validator function for US Employer Identification Numbers (EIN) in this Go validation library. The EIN validator should: + +1. Create a new tag called "ein" that validates if a string is a valid US Employer Identification Number +2. Follow the pattern of ##-#######, where # is a digit (regex pattern would be ^(\d{2}-\d{7})$) +3. Ensure the field contains exactly 10 characters (including the hyphen) +4. Document the new validator in the README.md and doc.go files +5. Add proper unit tests to verify validation works correctly for valid and invalid EINs diff --git a/crates/eval/examples/test_infrastructure/base.toml b/crates/eval/examples/test_infrastructure/base.toml new file mode 100644 index 0000000000..2a4fe2d3dd --- /dev/null +++ b/crates/eval/examples/test_infrastructure/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/dagster-io/dagster.git" +revision = "c9ed914a76baa6fb761a97f3236f96cd7d5361e6" +language_extension = "py" diff --git a/crates/eval/examples/test_infrastructure/criteria.md b/crates/eval/examples/test_infrastructure/criteria.md new file mode 100644 index 0000000000..0cdfe6b394 --- /dev/null +++ b/crates/eval/examples/test_infrastructure/criteria.md @@ -0,0 +1,3 @@ +1. Introduce a new docker-compose.yml file in the integration tests directory for the monitoring daemon test suite. This file defines two services: a PostgreSQL database with test credentials exposed on port 5432, and a localstack S3 service exposed on port 4566. These services provide the necessary infrastructure for running the monitoring tests. +2. Shows significant modifications to the test_monitoring.py file, including new imports (boto3, Path, and docker_compose_cm), removal of the dagster_aws tests import, and the addition of new fixtures. The new fixtures handle docker-compose setup, provide hostnames for services, configure AWS environment variables with test credentials, and initialize an S3 bucket for testing purposes. The changes reflect a shift from using external AWS credentials to using localstack for S3 testing. +3. Reveals structural changes to the test file, where the aws_env fixture has been moved from the bottom of the file to be grouped with other fixtures. The original implementation that relied on get_aws_creds() has been replaced with a new implementation that uses localstack with hardcoded test credentials, and the test_docker_monitoring_run_out_of_attempts function remains at the end of the file but now uses the new aws_env fixture implementation. diff --git a/crates/eval/examples/test_infrastructure/prompt.md b/crates/eval/examples/test_infrastructure/prompt.md new file mode 100644 index 0000000000..7428d6b362 --- /dev/null +++ b/crates/eval/examples/test_infrastructure/prompt.md @@ -0,0 +1 @@ +Refactor the monitoring daemon integration tests to use local Docker-managed dependencies instead of direct AWS dependencies. First, create a docker-compose.yml file with two services: a PostgreSQL container with test credentials exposed on port 5432, and a LocalStack S3 container exposed on port 4566. Next, modify the test file to remove reliance on external AWS credentials and replace them with fixtures that configure a LocalStack S3 mock. The fixtures should include session-scoped setup for hostnames, PostgreSQL connections, and AWS environment variables with hardcoded test credentials (e.g., fake access keys). Ensure the S3 fixture initializes a test bucket. Move the AWS environment fixture to align with other fixtures and update the test logic to use the new LocalStack endpoint URL, handling both local and Buildkite environments. Keep the core test cases (like monitoring run attempts) intact but adapt them to use the new Docker-based dependencies. diff --git a/crates/eval/examples/tool_response_handling/base.toml b/crates/eval/examples/tool_response_handling/base.toml new file mode 100644 index 0000000000..cd499cefb3 --- /dev/null +++ b/crates/eval/examples/tool_response_handling/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/block/goose.git" +revision = "d7308457fe3f1b9c7253de45b2f81ddc4f005fe5" +language_extension = "rs" diff --git a/crates/eval/examples/tool_response_handling/criteria.md b/crates/eval/examples/tool_response_handling/criteria.md new file mode 100644 index 0000000000..9aaaa83b43 --- /dev/null +++ b/crates/eval/examples/tool_response_handling/criteria.md @@ -0,0 +1,3 @@ +1. All Goose packages (`goose`, `goose-bench`, `goose-cli`, `goose-mcp`, `goose-server`) were updated from version `1.0.17` to `1.0.18` in `Cargo.lock`. These updates ensure compatibility and consistency across related packages. +2. The `goose-app` version in `ui/desktop/package-lock.json` was also updated to `1.0.18`, maintaining alignment with the backend and shared libraries. +3. In `App.tsx`, the `useConfig` hook was destructured to directly use `addExtension` instead of the older `addExtensionToConfig` function. All occurrences of the old function name were updated, including inside effects and async calls, to use the new unified method. This change simplifies extension handling logic while preserving current behavior. diff --git a/crates/eval/examples/tool_response_handling/prompt.md b/crates/eval/examples/tool_response_handling/prompt.md new file mode 100644 index 0000000000..3358ad6eec --- /dev/null +++ b/crates/eval/examples/tool_response_handling/prompt.md @@ -0,0 +1 @@ +Upgrade all Goose-related packages and apps from version 1.0.17 to 1.0.18 throughout the codebase. This includes updating version references in Cargo.lock, package-lock.json, and source files where applicable. In addition, streamline the addExtension logic in App.tsx by removing the outdated addExtensionToConfig references and replacing them with the new unified addExtension function. Ensure that all function dependencies and hooks reflect this updated usage. The goal is to improve maintainability and consistency across the codebase without introducing any functional changes. diff --git a/crates/eval/examples/toolbar_endpoints/base.toml b/crates/eval/examples/toolbar_endpoints/base.toml new file mode 100644 index 0000000000..016078cbdf --- /dev/null +++ b/crates/eval/examples/toolbar_endpoints/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/django-cms/django-cms.git" +revision = "0b775f27300c4347be18a5bb7b1b172d6a943ccf" +language_extension = "py" diff --git a/crates/eval/examples/toolbar_endpoints/criteria.md b/crates/eval/examples/toolbar_endpoints/criteria.md new file mode 100644 index 0000000000..cc2aba9271 --- /dev/null +++ b/crates/eval/examples/toolbar_endpoints/criteria.md @@ -0,0 +1,3 @@ +1. The changes add two new URL patterns ('cms_placeholder_add_plugin' and 'cms_placeholder_edit_plugin') to the list of endpoints in the toolbar middleware configuration. These endpoints will now be recognized by the toolbar system. +2. The changes add test cases for the new toolbar endpoints in the test file. The first test case verifies that the toolbar is properly attached to requests for the 'cms_placeholder_add_plugin' admin endpoint. The test creates a mock request and checks that the toolbar attribute is present after middleware processing. +3. The changes include a second test case that verifies toolbar functionality for the 'cms_placeholder_edit_plugin' admin endpoint. Similar to the first test, it creates a mock request with plugin ID (1) and checks for the presence of the toolbar attribute after middleware processing. This maintains consistency with the existing test for 'cms_placeholder_clear_placeholder'. diff --git a/crates/eval/examples/toolbar_endpoints/prompt.md b/crates/eval/examples/toolbar_endpoints/prompt.md new file mode 100644 index 0000000000..1291e80a78 --- /dev/null +++ b/crates/eval/examples/toolbar_endpoints/prompt.md @@ -0,0 +1,3 @@ +I'm working on improving the Django CMS toolbar middleware to better support plugin management functionality. Currently, the toolbar is only enabled for specific views defined in the `TOOLBAR_URL_PREFIXES` within toolbar.py, but I've noticed we're missing support for two critical plugin-related operations: adding and editing plugins through the `cms_placeholder_add_plugin` and `cms_placeholder_edit_plugin` views. These views should have access to the toolbar object just like our other administrative actions, as they're fundamental to the content editing experience. + +To implement this enhancement, we'll need to make two key changes. First, we should add both 'cms_placeholder_add_plugin' and 'cms_placeholder_edit_plugin' to the allowed URL prefixes list in cms/middleware/toolbar.py. Second, we should expand our test coverage in cms/tests/test_toolbar.py to verify that the toolbar object is properly attached to requests hitting these endpoints, maintaining consistency with how we test other toolbar-enabled views. This change will ensure a more complete and reliable toolbar experience throughout the entire plugin management workflow. diff --git a/crates/eval/examples/war_and_uri_corrections/base.toml b/crates/eval/examples/war_and_uri_corrections/base.toml new file mode 100644 index 0000000000..bcdfeb9614 --- /dev/null +++ b/crates/eval/examples/war_and_uri_corrections/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/jetty/jetty.project.git" +revision = "dc685b6f84e94ad2eb6a3930769e6eab0cab3fa6" +language_extension = "java" diff --git a/crates/eval/examples/war_and_uri_corrections/criteria.md b/crates/eval/examples/war_and_uri_corrections/criteria.md new file mode 100644 index 0000000000..1e263fd59f --- /dev/null +++ b/crates/eval/examples/war_and_uri_corrections/criteria.md @@ -0,0 +1,7 @@ +1. The changes add an import for `URIUtil` and modify the URL creation in `OSGiApp.java` to use `URIUtil.correctURI()` for proper URI handling. The modification ensures correct URI formatting before converting to URL. +2. The changes add an import for `URIUtil` and modify the URI creation in `Util.java` to use `URIUtil.correctURI()` when handling file paths. This ensures proper URI formatting for paths starting with "file:/". +3. The changes in both `WebInfConfiguration.java` files (EE10 and EE9 versions) refactor the war file handling logic. The modifications: + - Add explanatory comments about looking for sibling directories + - Change how the war path is obtained (using webApp.getPath() instead of creating new resources) + - Restructure the conditional logic for better clarity + - Maintain the same functionality but with improved safety checks and documentation diff --git a/crates/eval/examples/war_and_uri_corrections/prompt.md b/crates/eval/examples/war_and_uri_corrections/prompt.md new file mode 100644 index 0000000000..3c0ac029df --- /dev/null +++ b/crates/eval/examples/war_and_uri_corrections/prompt.md @@ -0,0 +1,7 @@ +I’m working on improvements to a Jetty OSGi application’s file path handling and deployment logic. The changes focus on two main areas: URI normalization and WAR file extraction. + +First, the URI handling logic needs updates to ensure consistent formatting, particularly when dealing with file paths. Currently, there are cases where paths aren’t properly normalized, especially when converting between file URIs and URLs. This affects both core OSGi resource resolution and utility methods that process path strings. The goal is to apply systematic corrections so that paths are reliably formatted across different scenarios. + +Second, the WAR file extraction process requires refinement to make it more robust. The current implementation checks for pre-extracted sibling directories, but the logic could be strengthened by using the resolved webApp path directly rather than reconstructing it from strings. Additionally, the code would benefit from clearer documentation and added safeguards to handle edge cases gracefully. These changes will apply to both the EE9 and EE10 WebApp configurations, ensuring consistent behavior across versions. + +The overarching aim is to reduce deployment failures and improve maintainability while keeping the changes backward-compatible. diff --git a/crates/eval/examples/window_title_support/base.toml b/crates/eval/examples/window_title_support/base.toml new file mode 100644 index 0000000000..3b0e37d2c2 --- /dev/null +++ b/crates/eval/examples/window_title_support/base.toml @@ -0,0 +1,3 @@ +url = "https://github.com/charmbracelet/bubbletea.git" +revision = "bc1c475eb0263aba13ef430f191677e153dc0320" +language_extension = "go" diff --git a/crates/eval/examples/window_title_support/criteria.md b/crates/eval/examples/window_title_support/criteria.md new file mode 100644 index 0000000000..d64b009b5a --- /dev/null +++ b/crates/eval/examples/window_title_support/criteria.md @@ -0,0 +1,4 @@ +1. Adds a new `setWindowTitle` method to the `standardRenderer` struct that sets the terminal window title using the OSC 0 escape sequence. It includes thread safety with mutex locking and uses fmt.Fprintf to send the escape sequence with the provided title. +2. Modifies the `handleMessages` method in `standardRenderer` to handle a new `setWindowTitleMsg` message type by calling the new `setWindowTitle` method. This completes the rendering-side implementation for window title updates. +3. Updates the event loop in the Program struct to properly handle `setWindowTitleMsg` messages by passing them through to the renderer without additional processing, similar to other renderer-specific messages. +4. Adds documentation to the commands tutorial README explaining how to set window titles in Bubble Tea applications. It shows examples of using `tea.SetWindowTitle()` in both Init and Update methods, and explains its usefulness for reflecting application state in the window title. diff --git a/crates/eval/examples/window_title_support/prompt.md b/crates/eval/examples/window_title_support/prompt.md new file mode 100644 index 0000000000..f47418f8a3 --- /dev/null +++ b/crates/eval/examples/window_title_support/prompt.md @@ -0,0 +1,11 @@ +I’d like to add the ability to set terminal window titles in our Bubble Tea framework. This would let applications dynamically update the title bar (e.g., to show status or app names). + +Requirements: + +Expose a user-friendly way to set titles (e.g., a SetWindowTitle command). +Ensure it works cross-platform with standard terminal escape codes. +Include a minimal example and docs showing usage. +Constraints: + +Follow existing patterns for commands/messages. +Thread-safe rendering. diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index cfdb00b655..975d547442 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -39,6 +39,9 @@ struct Args { /// Model to use (default: "claude-3-7-sonnet-latest") #[arg(long, default_value = "claude-3-7-sonnet-latest")] model: String, + /// Languages to run (comma-separated, e.g. "js,ts,py"). If unspecified, only Rust examples are run. + #[arg(long, value_delimiter = ',')] + languages: Option>, } fn main() { @@ -46,6 +49,8 @@ fn main() { let args = Args::parse(); let all_available_examples = list_all_examples().unwrap(); + let languages = args.languages.unwrap_or_else(|| vec!["rs".to_string()]); + let example_paths = all_available_examples .iter() .filter_map(|example_path| { @@ -94,6 +99,17 @@ fn main() { let mut examples = Vec::new(); for example_path in example_paths { let example = Example::load_from_directory(&example_path, &run_dir)?; + + if !example + .base + .language_extension + .as_ref() + .map_or(false, |lang| languages.contains(lang)) + { + println!("Skipping {}", example.name); + continue; + } + examples.push((example_path, example)); } let mut repo_urls = HashSet::new(); @@ -133,6 +149,10 @@ fn main() { future::join_all(clone_tasks).await; + for (_, example) in examples.iter() { + example.setup().await?; + } + let tasks = examples .into_iter() .map(|(example_path, example)| { @@ -197,7 +217,6 @@ async fn run_example( app_state: Arc, cx: &mut AsyncApp, ) -> Result { - example.setup().await?; cx.update(|cx| example.run(model.clone(), app_state, cx))? .await?; let diff = example.repository_diff().await?; diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index ff2f9282b1..dd7a87d8e2 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -115,6 +115,8 @@ impl Example { pub async fn setup(&self) -> Result<()> { let repo_path = repo_path_for_url(&self.base.url); + println!("{}> Fetching", self.name); + run_git( &repo_path, &["fetch", "--depth", "1", "origin", &self.base.revision], diff --git a/crates/eval/src/judge_prompt.hbs b/crates/eval/src/judge_prompt.hbs index cce120d52a..862cc0985c 100644 --- a/crates/eval/src/judge_prompt.hbs +++ b/crates/eval/src/judge_prompt.hbs @@ -11,6 +11,7 @@ Use the following criteria to score the above changes. Based on these criteria, give the test output a score between 0 and 5. +The output score should ONLY INCLUDE whole numbers. DO NOT return decimals or floats. - 5 means: changes meet all criteria - 0 means: changes don't meet any criteria diff --git a/typos.toml b/typos.toml index f4ec8a1220..1c90bf5926 100644 --- a/typos.toml +++ b/typos.toml @@ -41,6 +41,10 @@ extend-exclude = [ "docs/theme/css/", # Spellcheck triggers on `|Fixe[sd]|` regex part. "script/danger/dangerfile.ts", + # Eval examples for prompts and criteria + "crates/eval/examples/checkpoint_stability/criteria.md", + "crates/eval/examples/tax_id_validation/prompt.md", + "crates/eval/examples/tax_id_validation/criteria.md" ] [default] From 5f897b0e00fc9ae3b42a05ad7dd73640ea6a0266 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 14 Apr 2025 17:01:21 -0600 Subject: [PATCH 61/75] Agent Eval: Fail example when there are no events in 2 minutes (#28725) Release Notes: - N/A --- crates/eval/src/example.rs | 136 ++++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 62 deletions(-) diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index dd7a87d8e2..75be01c9d6 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -4,8 +4,8 @@ use assistant_tool::ToolWorkingSet; use client::proto::LspWorkProgress; use collections::HashMap; use dap::DapRegistry; -use futures::channel::{mpsc, oneshot}; -use futures::{FutureExt, StreamExt as _}; +use futures::channel::mpsc; +use futures::{FutureExt, StreamExt as _, select_biased}; use gpui::{App, AsyncApp, Entity, Task}; use handlebars::Handlebars; use language::{DiagnosticSeverity, OffsetRangeExt}; @@ -35,6 +35,8 @@ pub const EXAMPLES_DIR: &str = "./crates/eval/examples"; pub const REPOS_DIR: &str = "./crates/eval/repos"; pub const WORKTREES_DIR: &str = "./crates/eval/worktrees"; +const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2); + #[derive(Clone, Debug, Deserialize)] pub struct ExampleBase { pub url: String, @@ -277,77 +279,87 @@ impl Example { let tool_use_counts: Arc, u32>>> = Mutex::new(HashMap::default()).into(); - let (tx, rx) = oneshot::channel(); - let mut tx = Some(tx); + let (thread_event_tx, mut thread_event_rx) = mpsc::unbounded(); - let subscription = cx.subscribe(&thread, { + let subscription = cx.subscribe(&thread, move |_thread, event: &ThreadEvent, _cx| { + thread_event_tx.unbounded_send(event.clone()).log_err(); + }); + + let event_handler_task = cx.spawn({ let log_file = this.log_file.clone(); let name = this.name.clone(); let tool_use_counts = tool_use_counts.clone(); - move |thread, event: &ThreadEvent, cx| { - let mut log_file = log_file.lock().unwrap(); + let thread = thread.downgrade(); + async move |cx| { + loop { + let event = select_biased! { + event = thread_event_rx.next() => event, + _ = cx.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => { + return Err(anyhow!("Agentic loop stalled - waited {:?} without any events", THREAD_EVENT_TIMEOUT)); + } + }; + let Some(event) = event else { + return Err(anyhow!("ThreadEvent channel ended early")); + }; - match event { - ThreadEvent::Stopped(reason) => match reason { - Ok(StopReason::EndTurn) => { - if let Some(tx) = tx.take() { - tx.send(Ok(())).ok(); + let mut log_file = log_file.lock().unwrap(); + + match event { + ThreadEvent::Stopped(reason) => match reason { + Ok(StopReason::EndTurn) => { + return Ok(()); + } + Ok(StopReason::MaxTokens) => { + return Err(anyhow!("Exceeded maximum tokens")); + } + Ok(StopReason::ToolUse) => {} + Err(error) => { + return Err(anyhow!(error.clone())); + } + }, + ThreadEvent::ShowError(thread_error) => { + break Err(anyhow!(thread_error.clone())); + } + ThreadEvent::StreamedAssistantText(_, chunk) => { + write!(&mut log_file, "{}", chunk).log_err(); + } + ThreadEvent::StreamedAssistantThinking(_, chunk) => { + write!(&mut log_file, "{}", chunk).log_err(); + } + ThreadEvent::UsePendingTools { tool_uses } => { + writeln!(&mut log_file, "\n\nUSING TOOLS:").log_err(); + for tool_use in tool_uses { + writeln!(&mut log_file, "{}: {}", tool_use.name, tool_use.input) + .log_err(); } } - Ok(StopReason::MaxTokens) => { - if let Some(tx) = tx.take() { - tx.send(Err(anyhow!("Exceeded maximum tokens"))).ok(); + ThreadEvent::ToolFinished { + tool_use_id, + pending_tool_use, + .. + } => { + if let Some(tool_use) = pending_tool_use { + let message = format!("TOOL FINISHED: {}", tool_use.name); + println!("{name}> {message}"); + writeln!(&mut log_file, "\n{}", message).log_err(); } + thread.update(cx, |thread, _cx| { + if let Some(tool_result) = thread.tool_result(&tool_use_id) { + writeln!(&mut log_file, "\n{}\n", tool_result.content).log_err(); + let mut tool_use_counts = tool_use_counts.lock().unwrap(); + *tool_use_counts + .entry(tool_result.tool_name.clone()) + .or_insert(0) += 1; + } + })?; } - Ok(StopReason::ToolUse) => {} - Err(error) => { - if let Some(tx) = tx.take() { - tx.send(Err(anyhow!(error.clone()))).ok(); - } - } - }, - ThreadEvent::ShowError(thread_error) => { - if let Some(tx) = tx.take() { - tx.send(Err(anyhow!(thread_error.clone()))).ok(); - } + _ => {} } - ThreadEvent::StreamedAssistantText(_, chunk) => { - write!(&mut log_file, "{}", chunk).log_err(); - } - ThreadEvent::StreamedAssistantThinking(_, chunk) => { - write!(&mut log_file, "{}", chunk).log_err(); - } - ThreadEvent::UsePendingTools { tool_uses } => { - writeln!(&mut log_file, "\n\nUSING TOOLS:").log_err(); - for tool_use in tool_uses { - writeln!(&mut log_file, "{}: {}", tool_use.name, tool_use.input) - .log_err(); - } - } - ThreadEvent::ToolFinished { - tool_use_id, - pending_tool_use, - .. - } => { - if let Some(tool_use) = pending_tool_use { - let message = format!("TOOL FINISHED: {}", tool_use.name); - println!("{name}> {message}"); - writeln!(&mut log_file, "\n{}", message).log_err(); - } - if let Some(tool_result) = thread.read(cx).tool_result(tool_use_id) { - writeln!(&mut log_file, "\n{}\n", tool_result.content).log_err(); - let mut tool_use_counts = tool_use_counts.lock().unwrap(); - *tool_use_counts - .entry(tool_result.tool_name.clone()) - .or_insert(0) += 1; - } - } - _ => {} + + log_file.flush().log_err(); } - - log_file.flush().log_err(); } - })?; + }); thread.update(cx, |thread, cx| { let context = vec![]; @@ -355,7 +367,7 @@ impl Example { thread.send_to_model(model, RequestKind::Chat, cx); })?; - rx.await??; + event_handler_task.await?; if let Some((_, lsp_store)) = lsp_open_handle_and_store.as_ref() { wait_for_lang_server(lsp_store, this.name.clone(), cx).await?; From 0d6e455bf6bee6f35dfc695e9822263578c93b1e Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 14 Apr 2025 17:04:07 -0600 Subject: [PATCH 62/75] Agent eval: output paths to log files at the end (#28724) Release Notes: - N/A --- crates/eval/src/eval.rs | 27 +++++++++++++-------------- crates/eval/src/example.rs | 5 ++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 975d547442..eb2c07e0dc 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -110,13 +110,15 @@ fn main() { continue; } - examples.push((example_path, example)); + println!("{}> Logging to {:?}", example.name, example.log_file_path); + + examples.push(example); } let mut repo_urls = HashSet::new(); let mut clone_tasks = Vec::new(); - for (_, example) in examples.iter() { + for example in examples.iter() { let repo_url = example.base.url.clone(); if repo_urls.insert(repo_url.clone()) { let repo_path = repo_path_for_url(&repo_url); @@ -149,25 +151,22 @@ fn main() { future::join_all(clone_tasks).await; - for (_, example) in examples.iter() { + for example in examples.iter() { example.setup().await?; } let tasks = examples .into_iter() - .map(|(example_path, example)| { + .map(|example| { let app_state = app_state.clone(); let model = model.clone(); cx.spawn(async move |cx| { - ( - example_path, - run_example(example, model, app_state, cx).await, - ) + (run_example(&example, model, app_state, cx).await, example) }) }) .collect::>(); - let results: Vec<(PathBuf, Result)> = future::join_all(tasks).await; + let results: Vec<(Result, Example)> = future::join_all(tasks).await; println!("\n\n"); println!("========================================"); @@ -177,11 +176,11 @@ fn main() { let mut judge_scores = Vec::new(); - for (example_path, result) in results { - let example_name = example_path.file_name().unwrap().to_string_lossy(); + for (result, example) in results { + println!("📜 {:<30}: {:?}", example.name, example.log_file_path); match result { Err(err) => { - println!("💥 {:<30}: {:?}", example_name, err); + println!("💥 {:<30}: {:?}", example.name, err); } Ok(judge_output) => { const SCORES: [&str; 6] = ["💀", "😭", "😔", "😐", "🙂", "🤩"]; @@ -189,7 +188,7 @@ fn main() { println!( "{} {:<30}: {}", SCORES[judge_output.score.min(5) as usize], - example_name, + example.name, judge_output.score, ); judge_scores.push(judge_output.score); @@ -212,7 +211,7 @@ fn main() { } async fn run_example( - mut example: Example, + example: &Example, model: Arc, app_state: Arc, cx: &mut AsyncApp, diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 75be01c9d6..531b1ea275 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -58,6 +58,8 @@ pub struct Example { pub criteria: String, /// Markdown log file to append to pub log_file: Arc>, + /// Path to markdown log file + pub log_file_path: PathBuf, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -102,6 +104,7 @@ impl Example { prompt: fs::read_to_string(prompt_path.clone())?, criteria: fs::read_to_string(criteria_path.clone())?, log_file, + log_file_path, }) } @@ -400,7 +403,7 @@ impl Example { } pub async fn judge( - &mut self, + &self, model: Arc, repository_diff: String, cx: &AsyncApp, From 77f32582e27d4656526f61ff85a4ca1fbada2eaf Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 14 Apr 2025 20:18:18 -0300 Subject: [PATCH 63/75] agent: Add some design tweaks (#28726) Fine-tuning some areas of the Agent Panel design. Release Notes: - N/A --- crates/agent/src/active_thread.rs | 44 +++++++++++++++++++++++------- crates/agent/src/message_editor.rs | 2 +- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 57e2a78e95..bf50789646 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -12,12 +12,12 @@ use anyhow::Context as _; use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting}; use collections::{HashMap, HashSet}; use editor::scroll::Autoscroll; -use editor::{Editor, MultiBuffer}; +use editor::{Editor, EditorElement, EditorStyle, MultiBuffer}; use gpui::{ AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, Focusable, Hsla, ListAlignment, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful, StyleRefinement, Subscription, Task, - TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, WindowHandle, + TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage, pulsating_between, }; use language::{Buffer, LanguageRegistry}; @@ -33,7 +33,9 @@ use std::sync::Arc; use std::time::Duration; use text::ToPoint; use theme::ThemeSettings; -use ui::{Disclosure, IconButton, KeyBinding, Scrollbar, ScrollbarState, Tooltip, prelude::*}; +use ui::{ + Disclosure, IconButton, KeyBinding, Scrollbar, ScrollbarState, TextSize, Tooltip, prelude::*, +}; use util::ResultExt as _; use workspace::{OpenOptions, Workspace}; @@ -65,8 +67,6 @@ pub struct ActiveThread { open_feedback_editors: HashMap>, } -const MAX_UNCOLLAPSED_LINES_IN_CODE_BLOCK: usize = 5; - struct RenderedMessage { language_registry: Arc, segments: Vec, @@ -291,6 +291,8 @@ fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle { } } +const MAX_UNCOLLAPSED_LINES_IN_CODE_BLOCK: usize = 10; + fn render_markdown_code_block( message_id: MessageId, ix: usize, @@ -577,7 +579,7 @@ fn render_markdown_code_block( if is_expanded { this.h_full() } else { - this.max_h_40() + this.max_h_80() } }, ) @@ -1496,12 +1498,36 @@ impl ActiveThread { .when(!message_is_empty, |parent| { parent.child( if let Some(edit_message_editor) = edit_message_editor.clone() { + let settings = ThemeSettings::get_global(cx); + let font_size = TextSize::Small.rems(cx); + let line_height = font_size.to_pixels(window.rem_size()) * 1.5; + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + line_height: line_height.into(), + ..Default::default() + }; + div() .key_context("EditMessageEditor") .on_action(cx.listener(Self::cancel_editing_message)) .on_action(cx.listener(Self::confirm_editing_message)) .min_h_6() - .child(edit_message_editor) + .pt_1() + .child(EditorElement::new( + &edit_message_editor, + EditorStyle { + background: colors.editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + )) .into_any() } else { div() @@ -1666,11 +1692,9 @@ impl ActiveThread { ), Role::Assistant => v_flex() .id(("message-container", ix)) - .ml_2() + .ml_2p5() .pl_2() .pr_4() - .border_l_1() - .border_color(cx.theme().colors().border_variant) .children(message_content) .when(has_tool_uses, |parent| { parent.children( diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 6c1c76b26e..c61e99ad04 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -56,7 +56,7 @@ pub struct MessageEditor { _subscriptions: Vec, } -const MAX_EDITOR_LINES: usize = 3; +const MAX_EDITOR_LINES: usize = 8; impl MessageEditor { pub fn new( From 12b012eab375f97da9d05625c9a879b6d901ab8c Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 15 Apr 2025 01:31:45 +0200 Subject: [PATCH 64/75] language: Further optimize `language_for_file` (#28694) Follow-up to #28671 This primarily follows two ideas: 1. We currently take the element with the highest score which appears last in the iterator (see [`last_by_key`](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.max_by_key)), so we can also just reverse the iterator and take the first highest match instead. 2. Once we have a match with a given precedence, we are not interested in any matches with a lower or even the same priority, given what was established in 1. Thus, we also only have to check whether any language checked afterwards has a higher priority match. Furthermore, once we have a match with the highest possible precedence, there is no need to look for any more possible matches. Thus, this PR also adds short-circuiting for that scenario. Lastly, I also cleaned-up the custom suffix match (an empty glob-set will never match so no need to iterate there) as well reorder the zip-call in the content matches, as we never need the content if there is no first line pattern present for the checked languages. Release Notes: - N/A --- crates/language/src/language_registry.rs | 130 +++++++++++++++-------- 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index d7a4293ee4..7ba3f3b0ae 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -16,6 +16,8 @@ use futures::{ }; use globset::GlobSet; use gpui::{App, BackgroundExecutor, SharedString}; +use itertools::FoldWhile::{Continue, Done}; +use itertools::Itertools; use lsp::LanguageServerId; use parking_lot::{Mutex, RwLock}; use postage::watch; @@ -165,6 +167,20 @@ impl AvailableLanguage { } } +#[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] +enum LanguageMatchPrecedence { + #[default] + Undetermined, + PathOrContent, + UserConfigured, +} + +impl LanguageMatchPrecedence { + fn best_possible_match(&self) -> bool { + *self == LanguageMatchPrecedence::UserConfigured + } +} + enum AvailableGrammar { Native(tree_sitter::Language), Loaded(#[allow(unused)] PathBuf, tree_sitter::Language), @@ -602,12 +618,10 @@ impl LanguageRegistry { name: &str, ) -> impl Future>> + use<> { let name = UniCase::new(name); - let rx = self.get_or_load_language(|language_name, _| { - if UniCase::new(&language_name.0) == name { - 1 - } else { - 0 - } + let rx = self.get_or_load_language(|language_name, _, current_best_match| { + (current_best_match < LanguageMatchPrecedence::PathOrContent + && UniCase::new(&language_name.0) == name) + .then_some(LanguageMatchPrecedence::PathOrContent) }); async move { rx.await? } } @@ -617,17 +631,14 @@ impl LanguageRegistry { string: &str, ) -> impl Future>> { let string = UniCase::new(string); - let rx = self.get_or_load_language(|name, config| { - if UniCase::new(&name.0) == string - || config - .path_suffixes - .iter() - .any(|suffix| UniCase::new(suffix) == string) - { - 1 - } else { - 0 - } + let rx = self.get_or_load_language(|name, config, current_best_match| { + (current_best_match < LanguageMatchPrecedence::PathOrContent + && (UniCase::new(&name.0) == string + || config + .path_suffixes + .iter() + .any(|suffix| UniCase::new(suffix) == string))) + .then_some(LanguageMatchPrecedence::PathOrContent) }); async move { rx.await? } } @@ -688,7 +699,6 @@ impl LanguageRegistry { .iter() .filter_map(|suffix| suffix.map(globset::Candidate::new)) .collect::>(); - let empty = GlobSet::empty(); let content = LazyCell::new(|| { content.map(|content| { let end = content.clip_point(Point::new(0, 256), Bias::Left); @@ -696,7 +706,7 @@ impl LanguageRegistry { content.chunks_in_range(0..end).collect::() }) }); - self.find_matching_language(move |language_name, config| { + self.find_matching_language(move |language_name, config, current_best_match| { let path_matches_default_suffix = || { config .path_suffixes @@ -704,47 +714,75 @@ impl LanguageRegistry { .any(|suffix| path_suffixes.contains(&Some(suffix.as_str()))) }; let path_matches_custom_suffix = || { - let custom_suffixes = user_file_types + user_file_types .and_then(|types| types.get(language_name.as_ref())) - .unwrap_or(&empty); - path_suffixes_candidates - .iter() - .any(|suffix| custom_suffixes.is_match_candidate(suffix)) + .map_or(false, |custom_suffixes| { + path_suffixes_candidates + .iter() + .any(|suffix| custom_suffixes.is_match_candidate(suffix)) + }) }; let content_matches = || { - content - .as_ref() - .zip(config.first_line_pattern.as_ref()) - .map_or(false, |(text, pattern)| pattern.is_match(&text)) + config.first_line_pattern.as_ref().map_or(false, |pattern| { + content + .as_ref() + .is_some_and(|content| pattern.is_match(content)) + }) }; - if path_matches_custom_suffix() { - 2 - } else if path_matches_default_suffix() || content_matches() { - 1 - } else { - 0 + + // Only return a match for the given file if we have a better match than + // the current one. + match current_best_match { + LanguageMatchPrecedence::PathOrContent | LanguageMatchPrecedence::Undetermined + if path_matches_custom_suffix() => + { + Some(LanguageMatchPrecedence::UserConfigured) + } + LanguageMatchPrecedence::Undetermined + if path_matches_default_suffix() || content_matches() => + { + Some(LanguageMatchPrecedence::PathOrContent) + } + _ => None, } }) } fn find_matching_language( self: &Arc, - callback: impl Fn(&LanguageName, &LanguageMatcher) -> usize, + callback: impl Fn( + &LanguageName, + &LanguageMatcher, + LanguageMatchPrecedence, + ) -> Option, ) -> Option { let state = self.state.read(); let available_language = state .available_languages .iter() - .filter_map(|language| { - let score = callback(&language.name, &language.matcher); - if score > 0 { - Some((language.clone(), score)) - } else { - None + .rev() + .fold_while(None, |best_language_match, language| { + let current_match_type = best_language_match + .as_ref() + .map_or(LanguageMatchPrecedence::default(), |(_, score)| *score); + let language_score = + callback(&language.name, &language.matcher, current_match_type); + debug_assert!( + language_score.is_none_or(|new_score| new_score > current_match_type), + "Matching callback should only return a better match than the current one" + ); + + match language_score { + Some(new_score) if new_score.best_possible_match() => { + Done(Some((language.clone(), new_score))) + } + Some(new_score) if current_match_type < new_score => { + Continue(Some((language.clone(), new_score))) + } + _ => Continue(best_language_match), } }) - .max_by_key(|e| e.1) - .clone() + .into_inner() .map(|(available_language, _)| available_language); drop(state); available_language @@ -839,7 +877,11 @@ impl LanguageRegistry { fn get_or_load_language( self: &Arc, - callback: impl Fn(&LanguageName, &LanguageMatcher) -> usize, + callback: impl Fn( + &LanguageName, + &LanguageMatcher, + LanguageMatchPrecedence, + ) -> Option, ) -> oneshot::Receiver>> { let Some(language) = self.find_matching_language(callback) else { let (tx, rx) = oneshot::channel(); From fc1252b0cd5ab50d6f206cbf9258bee6738715a7 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 14 Apr 2025 19:47:14 -0400 Subject: [PATCH 65/75] collab: Remove LLM service (#28728) This PR removes the LLM service from collab, as it has been moved to Cloudflare. Release Notes: - N/A --- .github/workflows/deploy_collab.yml | 8 - Cargo.lock | 1 - crates/collab/Cargo.toml | 1 - crates/collab/src/lib.rs | 5 - crates/collab/src/llm.rs | 767 +----------------- crates/collab/src/llm/authorization.rs | 330 -------- crates/collab/src/llm/db.rs | 1 - crates/collab/src/llm/db/queries.rs | 1 - .../llm/db/queries/revoked_access_tokens.rs | 15 - crates/collab/src/llm/db/queries/usages.rs | 664 +-------------- crates/collab/src/llm/db/tables.rs | 2 - .../src/llm/db/tables/lifetime_usage.rs | 20 - .../src/llm/db/tables/revoked_access_token.rs | 19 - crates/collab/src/llm/db/tests.rs | 2 - .../collab/src/llm/db/tests/billing_tests.rs | 152 ---- crates/collab/src/llm/db/tests/usage_tests.rs | 306 ------- crates/collab/src/main.rs | 29 +- 17 files changed, 8 insertions(+), 2315 deletions(-) delete mode 100644 crates/collab/src/llm/authorization.rs delete mode 100644 crates/collab/src/llm/db/queries/revoked_access_tokens.rs delete mode 100644 crates/collab/src/llm/db/tables/lifetime_usage.rs delete mode 100644 crates/collab/src/llm/db/tables/revoked_access_token.rs delete mode 100644 crates/collab/src/llm/db/tests/billing_tests.rs delete mode 100644 crates/collab/src/llm/db/tests/usage_tests.rs diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index d921a08bf1..eb5875afcc 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -117,12 +117,10 @@ jobs: export ZED_KUBE_NAMESPACE=production export ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT=10 export ZED_API_LOAD_BALANCER_SIZE_UNIT=2 - export ZED_LLM_LOAD_BALANCER_SIZE_UNIT=2 elif [[ $GITHUB_REF_NAME = "collab-staging" ]]; then export ZED_KUBE_NAMESPACE=staging export ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT=1 export ZED_API_LOAD_BALANCER_SIZE_UNIT=1 - export ZED_LLM_LOAD_BALANCER_SIZE_UNIT=1 else echo "cowardly refusing to deploy from an unknown branch" exit 1 @@ -147,9 +145,3 @@ jobs: envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f - kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}" - - export ZED_SERVICE_NAME=llm - export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_LLM_LOAD_BALANCER_SIZE_UNIT - envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f - - kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch - echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}" diff --git a/Cargo.lock b/Cargo.lock index b1c75fe3f6..b95c9dce18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2942,7 +2942,6 @@ dependencies = [ name = "collab" version = "0.44.0" dependencies = [ - "anthropic", "anyhow", "assistant", "assistant_context_editor", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index f60131e0de..c4aa90e2c2 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -18,7 +18,6 @@ sqlite = ["sea-orm/sqlx-sqlite", "sqlx/sqlite"] test-support = ["sqlite"] [dependencies] -anthropic.workspace = true anyhow.workspace = true async-stripe.workspace = true async-tungstenite.workspace = true diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 334d015d4b..2e682d2878 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -253,7 +253,6 @@ impl Config { pub enum ServiceMode { Api, Collab, - Llm, All, } @@ -265,10 +264,6 @@ impl ServiceMode { pub fn is_api(&self) -> bool { matches!(self, Self::Api | Self::All) } - - pub fn is_llm(&self) -> bool { - matches!(self, Self::Llm | Self::All) - } } pub struct AppState { diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs index 8c6fd772df..13d503e7d4 100644 --- a/crates/collab/src/llm.rs +++ b/crates/collab/src/llm.rs @@ -1,448 +1,10 @@ -mod authorization; pub mod db; mod token; -use crate::api::CloudflareIpCountryHeader; -use crate::api::events::SnowflakeRow; -use crate::build_kinesis_client; -use crate::rpc::MIN_ACCOUNT_AGE_FOR_LLM_USE; -use crate::{Cents, Config, Error, Result, db::UserId, executor::Executor}; -use anyhow::{Context as _, anyhow}; -use authorization::authorize_access_to_language_model; -use axum::routing::get; -use axum::{ - Extension, Json, Router, TypedHeader, - body::Body, - http::{self, HeaderName, HeaderValue, Request, StatusCode}, - middleware::{self, Next}, - response::{IntoResponse, Response}, - routing::post, -}; -use chrono::{DateTime, Duration, Utc}; -use collections::HashMap; -use db::TokenUsage; -use db::{ActiveUserCount, LlmDatabase, usage_measure::UsageMeasure}; -use futures::{Stream, StreamExt as _}; -use reqwest_client::ReqwestClient; -use rpc::{ - EXPIRED_LLM_TOKEN_HEADER_NAME, LanguageModelProvider, PerformCompletionParams, proto::Plan, -}; -use rpc::{ListModelsResponse, MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME}; -use serde_json::json; -use std::{ - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; -use strum::IntoEnumIterator; -use tokio::sync::RwLock; -use util::ResultExt; +use crate::Cents; pub use token::*; -const ACTIVE_USER_COUNT_CACHE_DURATION: Duration = Duration::seconds(30); - -pub struct LlmState { - pub config: Config, - pub executor: Executor, - pub db: Arc, - pub http_client: ReqwestClient, - pub kinesis_client: Option, - active_user_count_by_model: - RwLock, ActiveUserCount)>>, -} - -impl LlmState { - pub async fn new(config: Config, executor: Executor) -> Result> { - let database_url = config - .llm_database_url - .as_ref() - .ok_or_else(|| anyhow!("missing LLM_DATABASE_URL"))?; - let max_connections = config - .llm_database_max_connections - .ok_or_else(|| anyhow!("missing LLM_DATABASE_MAX_CONNECTIONS"))?; - - let mut db_options = db::ConnectOptions::new(database_url); - db_options.max_connections(max_connections); - let mut db = LlmDatabase::new(db_options, executor.clone()).await?; - db.initialize().await?; - - let db = Arc::new(db); - - let user_agent = format!("Zed Server/{}", env!("CARGO_PKG_VERSION")); - let http_client = - ReqwestClient::user_agent(&user_agent).context("failed to construct http client")?; - - let this = Self { - executor, - db, - http_client, - kinesis_client: if config.kinesis_access_key.is_some() { - build_kinesis_client(&config).await.log_err() - } else { - None - }, - active_user_count_by_model: RwLock::new(HashMap::default()), - config, - }; - - Ok(Arc::new(this)) - } - - pub async fn get_active_user_count( - &self, - provider: LanguageModelProvider, - model: &str, - ) -> Result { - let now = Utc::now(); - - { - let active_user_count_by_model = self.active_user_count_by_model.read().await; - if let Some((last_updated, count)) = - active_user_count_by_model.get(&(provider, model.to_string())) - { - if now - *last_updated < ACTIVE_USER_COUNT_CACHE_DURATION { - return Ok(*count); - } - } - } - - let mut cache = self.active_user_count_by_model.write().await; - let new_count = self.db.get_active_user_count(provider, model, now).await?; - cache.insert((provider, model.to_string()), (now, new_count)); - Ok(new_count) - } -} - -pub fn routes() -> Router<(), Body> { - Router::new() - .route("/models", get(list_models)) - .route("/completion", post(perform_completion)) - .layer(middleware::from_fn(validate_api_token)) -} - -async fn validate_api_token(mut req: Request, next: Next) -> impl IntoResponse { - let token = req - .headers() - .get(http::header::AUTHORIZATION) - .and_then(|header| header.to_str().ok()) - .ok_or_else(|| { - Error::http( - StatusCode::BAD_REQUEST, - "missing authorization header".to_string(), - ) - })? - .strip_prefix("Bearer ") - .ok_or_else(|| { - Error::http( - StatusCode::BAD_REQUEST, - "invalid authorization header".to_string(), - ) - })?; - - let state = req.extensions().get::>().unwrap(); - match LlmTokenClaims::validate(token, &state.config) { - Ok(claims) => { - if state.db.is_access_token_revoked(&claims.jti).await? { - return Err(Error::http( - StatusCode::UNAUTHORIZED, - "unauthorized".to_string(), - )); - } - - tracing::Span::current() - .record("user_id", claims.user_id) - .record("login", claims.github_user_login.clone()) - .record("authn.jti", &claims.jti) - .record("is_staff", claims.is_staff); - - req.extensions_mut().insert(claims); - Ok::<_, Error>(next.run(req).await.into_response()) - } - Err(ValidateLlmTokenError::Expired) => Err(Error::Http( - StatusCode::UNAUTHORIZED, - "unauthorized".to_string(), - [( - HeaderName::from_static(EXPIRED_LLM_TOKEN_HEADER_NAME), - HeaderValue::from_static("true"), - )] - .into_iter() - .collect(), - )), - Err(_err) => Err(Error::http( - StatusCode::UNAUTHORIZED, - "unauthorized".to_string(), - )), - } -} - -async fn list_models( - Extension(state): Extension>, - Extension(claims): Extension, - country_code_header: Option>, -) -> Result> { - let country_code = country_code_header.map(|header| header.to_string()); - - let mut accessible_models = Vec::new(); - - for (provider, model) in state.db.all_models() { - let authorize_result = authorize_access_to_language_model( - &state.config, - &claims, - country_code.as_deref(), - provider, - &model.name, - ); - - if authorize_result.is_ok() { - accessible_models.push(rpc::LanguageModel { - provider, - name: model.name, - }); - } - } - - Ok(Json(ListModelsResponse { - models: accessible_models, - })) -} - -async fn perform_completion( - Extension(state): Extension>, - Extension(claims): Extension, - country_code_header: Option>, - Json(params): Json, -) -> Result { - let model = normalize_model_name( - state.db.model_names_for_provider(params.provider), - params.model, - ); - - let bypass_account_age_check = claims.has_llm_subscription || claims.bypass_account_age_check; - if !bypass_account_age_check { - if Utc::now().naive_utc() - claims.account_created_at < MIN_ACCOUNT_AGE_FOR_LLM_USE { - Err(anyhow!("account too young"))? - } - } - - authorize_access_to_language_model( - &state.config, - &claims, - country_code_header - .map(|header| header.to_string()) - .as_deref(), - params.provider, - &model, - )?; - - check_usage_limit(&state, params.provider, &model, &claims).await?; - - let stream = match params.provider { - LanguageModelProvider::Anthropic => { - let api_key = if claims.is_staff { - state - .config - .anthropic_staff_api_key - .as_ref() - .context("no Anthropic AI staff API key configured on the server")? - } else { - state - .config - .anthropic_api_key - .as_ref() - .context("no Anthropic AI API key configured on the server")? - }; - - let mut request: anthropic::Request = - serde_json::from_str(params.provider_request.get())?; - - // Override the model on the request with the latest version of the model that is - // known to the server. - // - // Right now, we use the version that's defined in `model.id()`, but we will likely - // want to change this code once a new version of an Anthropic model is released, - // so that users can use the new version, without having to update Zed. - request.model = match model.as_str() { - "claude-3-5-sonnet" => anthropic::Model::Claude3_5Sonnet.id().to_string(), - "claude-3-7-sonnet" => anthropic::Model::Claude3_7Sonnet.id().to_string(), - "claude-3-opus" => anthropic::Model::Claude3Opus.id().to_string(), - "claude-3-haiku" => anthropic::Model::Claude3Haiku.id().to_string(), - "claude-3-sonnet" => anthropic::Model::Claude3Sonnet.id().to_string(), - _ => request.model, - }; - - let (chunks, rate_limit_info) = anthropic::stream_completion_with_rate_limit_info( - &state.http_client, - anthropic::ANTHROPIC_API_URL, - api_key, - request, - ) - .await - .map_err(|err| match err { - anthropic::AnthropicError::ApiError(ref api_error) => match api_error.code() { - Some(anthropic::ApiErrorCode::RateLimitError) => { - tracing::info!( - target: "upstream rate limit exceeded", - user_id = claims.user_id, - login = claims.github_user_login, - authn.jti = claims.jti, - is_staff = claims.is_staff, - provider = params.provider.to_string(), - model = model - ); - - Error::http( - StatusCode::TOO_MANY_REQUESTS, - "Upstream Anthropic rate limit exceeded.".to_string(), - ) - } - Some(anthropic::ApiErrorCode::InvalidRequestError) => { - Error::http(StatusCode::BAD_REQUEST, api_error.message.clone()) - } - Some(anthropic::ApiErrorCode::OverloadedError) => { - Error::http(StatusCode::SERVICE_UNAVAILABLE, api_error.message.clone()) - } - Some(_) => { - Error::http(StatusCode::INTERNAL_SERVER_ERROR, api_error.message.clone()) - } - None => Error::Internal(anyhow!(err)), - }, - anthropic::AnthropicError::Other(err) => Error::Internal(err), - })?; - - if let Some(rate_limit_info) = rate_limit_info { - tracing::info!( - target: "upstream rate limit", - is_staff = claims.is_staff, - provider = params.provider.to_string(), - model = model, - tokens_remaining = rate_limit_info.tokens.as_ref().map(|limits| limits.remaining), - input_tokens_remaining = rate_limit_info.input_tokens.as_ref().map(|limits| limits.remaining), - output_tokens_remaining = rate_limit_info.output_tokens.as_ref().map(|limits| limits.remaining), - requests_remaining = rate_limit_info.requests.as_ref().map(|limits| limits.remaining), - requests_reset = ?rate_limit_info.requests.as_ref().map(|limits| limits.reset), - tokens_reset = ?rate_limit_info.tokens.as_ref().map(|limits| limits.reset), - input_tokens_reset = ?rate_limit_info.input_tokens.as_ref().map(|limits| limits.reset), - output_tokens_reset = ?rate_limit_info.output_tokens.as_ref().map(|limits| limits.reset), - ); - } - - chunks - .map(move |event| { - let chunk = event?; - let ( - input_tokens, - output_tokens, - cache_creation_input_tokens, - cache_read_input_tokens, - ) = match &chunk { - anthropic::Event::MessageStart { - message: anthropic::Response { usage, .. }, - } - | anthropic::Event::MessageDelta { usage, .. } => ( - usage.input_tokens.unwrap_or(0) as usize, - usage.output_tokens.unwrap_or(0) as usize, - usage.cache_creation_input_tokens.unwrap_or(0) as usize, - usage.cache_read_input_tokens.unwrap_or(0) as usize, - ), - _ => (0, 0, 0, 0), - }; - - anyhow::Ok(CompletionChunk { - bytes: serde_json::to_vec(&chunk).unwrap(), - input_tokens, - output_tokens, - cache_creation_input_tokens, - cache_read_input_tokens, - }) - }) - .boxed() - } - LanguageModelProvider::OpenAi => { - let api_key = state - .config - .openai_api_key - .as_ref() - .context("no OpenAI API key configured on the server")?; - let chunks = open_ai::stream_completion( - &state.http_client, - open_ai::OPEN_AI_API_URL, - api_key, - serde_json::from_str(params.provider_request.get())?, - ) - .await?; - - chunks - .map(|event| { - event.map(|chunk| { - let input_tokens = - chunk.usage.as_ref().map_or(0, |u| u.prompt_tokens) as usize; - let output_tokens = - chunk.usage.as_ref().map_or(0, |u| u.completion_tokens) as usize; - CompletionChunk { - bytes: serde_json::to_vec(&chunk).unwrap(), - input_tokens, - output_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - } - }) - }) - .boxed() - } - LanguageModelProvider::Google => { - let api_key = state - .config - .google_ai_api_key - .as_ref() - .context("no Google AI API key configured on the server")?; - let chunks = google_ai::stream_generate_content( - &state.http_client, - google_ai::API_URL, - api_key, - serde_json::from_str(params.provider_request.get())?, - ) - .await?; - - chunks - .map(|event| { - event.map(|chunk| { - // TODO - implement token counting for Google AI - CompletionChunk { - bytes: serde_json::to_vec(&chunk).unwrap(), - input_tokens: 0, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - } - }) - }) - .boxed() - } - }; - - Ok(Response::new(Body::wrap_stream(TokenCountingStream { - state, - claims, - provider: params.provider, - model, - tokens: TokenUsage::default(), - inner_stream: stream, - }))) -} - -fn normalize_model_name(known_models: Vec, name: String) -> String { - if let Some(known_model_name) = known_models - .iter() - .filter(|known_model_name| name.starts_with(known_model_name.as_str())) - .max_by_key(|known_model_name| known_model_name.len()) - { - known_model_name.to_string() - } else { - name - } -} - /// The maximum monthly spending an individual user can reach on the free tier /// before they have to pay. pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(10); @@ -452,330 +14,3 @@ pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(10); /// /// Used to prevent surprise bills. pub const DEFAULT_MAX_MONTHLY_SPEND: Cents = Cents::from_dollars(10); - -async fn check_usage_limit( - state: &Arc, - provider: LanguageModelProvider, - model_name: &str, - claims: &LlmTokenClaims, -) -> Result<()> { - if claims.is_staff { - return Ok(()); - } - - let user_id = UserId::from_proto(claims.user_id); - let model = state.db.model(provider, model_name)?; - let free_tier = claims.free_tier_monthly_spending_limit(); - - let spending_this_month = state - .db - .get_user_spending_for_month(user_id, Utc::now()) - .await?; - if spending_this_month >= free_tier { - if !claims.has_llm_subscription { - return Err(Error::http( - StatusCode::PAYMENT_REQUIRED, - "Maximum spending limit reached for this month.".to_string(), - )); - } - - let monthly_spend = spending_this_month.saturating_sub(free_tier); - if monthly_spend >= Cents(claims.max_monthly_spend_in_cents) { - return Err(Error::Http( - StatusCode::FORBIDDEN, - "Maximum spending limit reached for this month.".to_string(), - [( - HeaderName::from_static(MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME), - HeaderValue::from_static("true"), - )] - .into_iter() - .collect(), - )); - } - } - - let active_users = state.get_active_user_count(provider, model_name).await?; - - let users_in_recent_minutes = active_users.users_in_recent_minutes.max(1); - let users_in_recent_days = active_users.users_in_recent_days.max(1); - - let per_user_max_requests_per_minute = - model.max_requests_per_minute as usize / users_in_recent_minutes; - let per_user_max_tokens_per_minute = - model.max_tokens_per_minute as usize / users_in_recent_minutes; - let per_user_max_input_tokens_per_minute = - model.max_input_tokens_per_minute as usize / users_in_recent_minutes; - let per_user_max_output_tokens_per_minute = - model.max_output_tokens_per_minute as usize / users_in_recent_minutes; - let per_user_max_tokens_per_day = model.max_tokens_per_day as usize / users_in_recent_days; - - let usage = state - .db - .get_usage(user_id, provider, model_name, Utc::now()) - .await?; - - let checks = match (provider, model_name) { - (LanguageModelProvider::Anthropic, "claude-3-7-sonnet") => vec![ - ( - usage.requests_this_minute, - per_user_max_requests_per_minute, - UsageMeasure::RequestsPerMinute, - ), - ( - usage.input_tokens_this_minute, - per_user_max_tokens_per_minute, - UsageMeasure::InputTokensPerMinute, - ), - ( - usage.output_tokens_this_minute, - per_user_max_tokens_per_minute, - UsageMeasure::OutputTokensPerMinute, - ), - ( - usage.tokens_this_day, - per_user_max_tokens_per_day, - UsageMeasure::TokensPerDay, - ), - ], - _ => vec![ - ( - usage.requests_this_minute, - per_user_max_requests_per_minute, - UsageMeasure::RequestsPerMinute, - ), - ( - usage.tokens_this_minute, - per_user_max_tokens_per_minute, - UsageMeasure::TokensPerMinute, - ), - ( - usage.tokens_this_day, - per_user_max_tokens_per_day, - UsageMeasure::TokensPerDay, - ), - ], - }; - - for (used, limit, usage_measure) in checks { - if used > limit { - let resource = match usage_measure { - UsageMeasure::RequestsPerMinute => "requests_per_minute", - UsageMeasure::TokensPerMinute => "tokens_per_minute", - UsageMeasure::InputTokensPerMinute => "input_tokens_per_minute", - UsageMeasure::OutputTokensPerMinute => "output_tokens_per_minute", - UsageMeasure::TokensPerDay => "tokens_per_day", - }; - - tracing::info!( - target: "user rate limit", - user_id = claims.user_id, - login = claims.github_user_login, - authn.jti = claims.jti, - is_staff = claims.is_staff, - provider = provider.to_string(), - model = model.name, - usage_measure = resource, - requests_this_minute = usage.requests_this_minute, - tokens_this_minute = usage.tokens_this_minute, - input_tokens_this_minute = usage.input_tokens_this_minute, - output_tokens_this_minute = usage.output_tokens_this_minute, - tokens_this_day = usage.tokens_this_day, - users_in_recent_minutes = users_in_recent_minutes, - users_in_recent_days = users_in_recent_days, - max_requests_per_minute = per_user_max_requests_per_minute, - max_tokens_per_minute = per_user_max_tokens_per_minute, - max_input_tokens_per_minute = per_user_max_input_tokens_per_minute, - max_output_tokens_per_minute = per_user_max_output_tokens_per_minute, - max_tokens_per_day = per_user_max_tokens_per_day, - ); - - SnowflakeRow::new( - "Language Model Rate Limited", - Some(claims.metrics_id), - claims.is_staff, - claims.system_id.clone(), - json!({ - "usage": usage, - "users_in_recent_minutes": users_in_recent_minutes, - "users_in_recent_days": users_in_recent_days, - "max_requests_per_minute": per_user_max_requests_per_minute, - "max_tokens_per_minute": per_user_max_tokens_per_minute, - "max_input_tokens_per_minute": per_user_max_input_tokens_per_minute, - "max_output_tokens_per_minute": per_user_max_output_tokens_per_minute, - "max_tokens_per_day": per_user_max_tokens_per_day, - "plan": match claims.plan { - Plan::Free => "free".to_string(), - Plan::ZedPro => "zed_pro".to_string(), - }, - "model": model.name.clone(), - "provider": provider.to_string(), - "usage_measure": resource.to_string(), - }), - ) - .write(&state.kinesis_client, &state.config.kinesis_stream) - .await - .log_err(); - - return Err(Error::http( - StatusCode::TOO_MANY_REQUESTS, - format!("Rate limit exceeded. Maximum {} reached.", resource), - )); - } - } - - Ok(()) -} - -struct CompletionChunk { - bytes: Vec, - input_tokens: usize, - output_tokens: usize, - cache_creation_input_tokens: usize, - cache_read_input_tokens: usize, -} - -struct TokenCountingStream { - state: Arc, - claims: LlmTokenClaims, - provider: LanguageModelProvider, - model: String, - tokens: TokenUsage, - inner_stream: S, -} - -impl Stream for TokenCountingStream -where - S: Stream> + Unpin, -{ - type Item = Result, anyhow::Error>; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match Pin::new(&mut self.inner_stream).poll_next(cx) { - Poll::Ready(Some(Ok(mut chunk))) => { - chunk.bytes.push(b'\n'); - self.tokens.input += chunk.input_tokens; - self.tokens.output += chunk.output_tokens; - self.tokens.input_cache_creation += chunk.cache_creation_input_tokens; - self.tokens.input_cache_read += chunk.cache_read_input_tokens; - Poll::Ready(Some(Ok(chunk.bytes))) - } - Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending, - } - } -} - -impl Drop for TokenCountingStream { - fn drop(&mut self) { - let state = self.state.clone(); - let claims = self.claims.clone(); - let provider = self.provider; - let model = std::mem::take(&mut self.model); - let tokens = self.tokens; - self.state.executor.spawn_detached(async move { - let usage = state - .db - .record_usage( - UserId::from_proto(claims.user_id), - claims.is_staff, - provider, - &model, - tokens, - claims.has_llm_subscription, - Cents(claims.max_monthly_spend_in_cents), - claims.free_tier_monthly_spending_limit(), - Utc::now(), - ) - .await - .log_err(); - - if let Some(usage) = usage { - tracing::info!( - target: "user usage", - user_id = claims.user_id, - login = claims.github_user_login, - authn.jti = claims.jti, - is_staff = claims.is_staff, - provider = provider.to_string(), - model = model, - requests_this_minute = usage.requests_this_minute, - tokens_this_minute = usage.tokens_this_minute, - input_tokens_this_minute = usage.input_tokens_this_minute, - output_tokens_this_minute = usage.output_tokens_this_minute, - ); - - let properties = json!({ - "has_llm_subscription": claims.has_llm_subscription, - "max_monthly_spend_in_cents": claims.max_monthly_spend_in_cents, - "plan": match claims.plan { - Plan::Free => "free".to_string(), - Plan::ZedPro => "zed_pro".to_string(), - }, - "model": model, - "provider": provider, - "usage": usage, - "tokens": tokens - }); - SnowflakeRow::new( - "Language Model Used", - Some(claims.metrics_id), - claims.is_staff, - claims.system_id.clone(), - properties, - ) - .write(&state.kinesis_client, &state.config.kinesis_stream) - .await - .log_err(); - } - }) - } -} - -pub fn log_usage_periodically(state: Arc) { - state.executor.clone().spawn_detached(async move { - loop { - state - .executor - .sleep(std::time::Duration::from_secs(30)) - .await; - - for provider in LanguageModelProvider::iter() { - for model in state.db.model_names_for_provider(provider) { - if let Some(active_user_count) = state - .get_active_user_count(provider, &model) - .await - .log_err() - { - tracing::info!( - target: "active user counts", - provider = provider.to_string(), - model = model, - users_in_recent_minutes = active_user_count.users_in_recent_minutes, - users_in_recent_days = active_user_count.users_in_recent_days, - ); - } - } - } - - if let Some(usages) = state - .db - .get_application_wide_usages_by_model(Utc::now()) - .await - .log_err() - { - for usage in usages { - tracing::info!( - target: "computed usage", - provider = usage.provider.to_string(), - model = usage.model, - requests_this_minute = usage.requests_this_minute, - tokens_this_minute = usage.tokens_this_minute, - input_tokens_this_minute = usage.input_tokens_this_minute, - output_tokens_this_minute = usage.output_tokens_this_minute, - ); - } - } - } - }) -} diff --git a/crates/collab/src/llm/authorization.rs b/crates/collab/src/llm/authorization.rs deleted file mode 100644 index 1ce7d7afdc..0000000000 --- a/crates/collab/src/llm/authorization.rs +++ /dev/null @@ -1,330 +0,0 @@ -use reqwest::StatusCode; -use rpc::LanguageModelProvider; - -use crate::llm::LlmTokenClaims; -use crate::{Config, Error, Result}; - -pub fn authorize_access_to_language_model( - config: &Config, - claims: &LlmTokenClaims, - country_code: Option<&str>, - provider: LanguageModelProvider, - model: &str, -) -> Result<()> { - authorize_access_for_country(config, country_code, provider)?; - authorize_access_to_model(config, claims, provider, model)?; - Ok(()) -} - -fn authorize_access_to_model( - config: &Config, - claims: &LlmTokenClaims, - provider: LanguageModelProvider, - model: &str, -) -> Result<()> { - if claims.is_staff { - return Ok(()); - } - - if provider == LanguageModelProvider::Anthropic { - if model == "claude-3-5-sonnet" || model == "claude-3-7-sonnet" { - return Ok(()); - } - - if claims.has_llm_closed_beta_feature_flag - && Some(model) == config.llm_closed_beta_model_name.as_deref() - { - return Ok(()); - } - } - - Err(Error::http( - StatusCode::FORBIDDEN, - format!("access to model {model:?} is not included in your plan"), - )) -} - -fn authorize_access_for_country( - config: &Config, - country_code: Option<&str>, - provider: LanguageModelProvider, -) -> Result<()> { - // In development we won't have the `CF-IPCountry` header, so we can't check - // the country code. - // - // This shouldn't be necessary, as anyone running in development will need to provide - // their own API credentials in order to use an LLM provider. - if config.is_development() { - return Ok(()); - } - - // https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-ipcountry - let country_code = match country_code { - // `XX` - Used for clients without country code data. - None | Some("XX") => Err(Error::http( - StatusCode::BAD_REQUEST, - "no country code".to_string(), - ))?, - // `T1` - Used for clients using the Tor network. - Some("T1") => Err(Error::http( - StatusCode::FORBIDDEN, - format!("access to {provider:?} models is not available over Tor"), - ))?, - Some(country_code) => country_code, - }; - - let is_country_supported_by_provider = match provider { - LanguageModelProvider::Anthropic => anthropic::is_supported_country(country_code), - LanguageModelProvider::OpenAi => open_ai::is_supported_country(country_code), - LanguageModelProvider::Google => google_ai::is_supported_country(country_code), - }; - if !is_country_supported_by_provider { - Err(Error::http( - StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS, - format!( - "access to {provider:?} models is not available in your region ({country_code})" - ), - ))? - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use axum::response::IntoResponse; - use pretty_assertions::assert_eq; - use rpc::proto::Plan; - - use super::*; - - #[gpui::test] - async fn test_authorize_access_to_language_model_with_supported_country( - _cx: &mut gpui::TestAppContext, - ) { - let config = Config::test(); - - let claims = LlmTokenClaims { - user_id: 99, - plan: Plan::ZedPro, - is_staff: true, - ..Default::default() - }; - - let cases = vec![ - (LanguageModelProvider::Anthropic, "US"), // United States - (LanguageModelProvider::Anthropic, "GB"), // United Kingdom - (LanguageModelProvider::OpenAi, "US"), // United States - (LanguageModelProvider::OpenAi, "GB"), // United Kingdom - (LanguageModelProvider::Google, "US"), // United States - (LanguageModelProvider::Google, "GB"), // United Kingdom - ]; - - for (provider, country_code) in cases { - authorize_access_to_language_model( - &config, - &claims, - Some(country_code), - provider, - "the-model", - ) - .unwrap_or_else(|_| { - panic!("expected authorization to return Ok for {provider:?}: {country_code}") - }) - } - } - - #[gpui::test] - async fn test_authorize_access_to_language_model_with_unsupported_country( - _cx: &mut gpui::TestAppContext, - ) { - let config = Config::test(); - - let claims = LlmTokenClaims { - user_id: 99, - plan: Plan::ZedPro, - ..Default::default() - }; - - let cases = vec![ - (LanguageModelProvider::Anthropic, "AF"), // Afghanistan - (LanguageModelProvider::Anthropic, "BY"), // Belarus - (LanguageModelProvider::Anthropic, "CF"), // Central African Republic - (LanguageModelProvider::Anthropic, "CN"), // China - (LanguageModelProvider::Anthropic, "CU"), // Cuba - (LanguageModelProvider::Anthropic, "ER"), // Eritrea - (LanguageModelProvider::Anthropic, "ET"), // Ethiopia - (LanguageModelProvider::Anthropic, "IR"), // Iran - (LanguageModelProvider::Anthropic, "KP"), // North Korea - (LanguageModelProvider::Anthropic, "XK"), // Kosovo - (LanguageModelProvider::Anthropic, "LY"), // Libya - (LanguageModelProvider::Anthropic, "MM"), // Myanmar - (LanguageModelProvider::Anthropic, "RU"), // Russia - (LanguageModelProvider::Anthropic, "SO"), // Somalia - (LanguageModelProvider::Anthropic, "SS"), // South Sudan - (LanguageModelProvider::Anthropic, "SD"), // Sudan - (LanguageModelProvider::Anthropic, "SY"), // Syria - (LanguageModelProvider::Anthropic, "VE"), // Venezuela - (LanguageModelProvider::Anthropic, "YE"), // Yemen - (LanguageModelProvider::OpenAi, "KP"), // North Korea - (LanguageModelProvider::Google, "KP"), // North Korea - ]; - - for (provider, country_code) in cases { - let error_response = authorize_access_to_language_model( - &config, - &claims, - Some(country_code), - provider, - "the-model", - ) - .expect_err(&format!( - "expected authorization to return an error for {provider:?}: {country_code}" - )) - .into_response(); - - assert_eq!( - error_response.status(), - StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS - ); - let response_body = hyper::body::to_bytes(error_response.into_body()) - .await - .unwrap() - .to_vec(); - assert_eq!( - String::from_utf8(response_body).unwrap(), - format!( - "access to {provider:?} models is not available in your region ({country_code})" - ) - ); - } - } - - #[gpui::test] - async fn test_authorize_access_to_language_model_with_tor(_cx: &mut gpui::TestAppContext) { - let config = Config::test(); - - let claims = LlmTokenClaims { - user_id: 99, - plan: Plan::ZedPro, - ..Default::default() - }; - - let cases = vec![ - (LanguageModelProvider::Anthropic, "T1"), // Tor - (LanguageModelProvider::OpenAi, "T1"), // Tor - (LanguageModelProvider::Google, "T1"), // Tor - ]; - - for (provider, country_code) in cases { - let error_response = authorize_access_to_language_model( - &config, - &claims, - Some(country_code), - provider, - "the-model", - ) - .expect_err(&format!( - "expected authorization to return an error for {provider:?}: {country_code}" - )) - .into_response(); - - assert_eq!(error_response.status(), StatusCode::FORBIDDEN); - let response_body = hyper::body::to_bytes(error_response.into_body()) - .await - .unwrap() - .to_vec(); - assert_eq!( - String::from_utf8(response_body).unwrap(), - format!("access to {provider:?} models is not available over Tor") - ); - } - } - - #[gpui::test] - async fn test_authorize_access_to_language_model_based_on_plan() { - let config = Config::test(); - - let test_cases = vec![ - // Pro plan should have access to claude-3.5-sonnet - ( - Plan::ZedPro, - LanguageModelProvider::Anthropic, - "claude-3-5-sonnet", - true, - ), - // Free plan should have access to claude-3.5-sonnet - ( - Plan::Free, - LanguageModelProvider::Anthropic, - "claude-3-5-sonnet", - true, - ), - // Pro plan should NOT have access to other Anthropic models - ( - Plan::ZedPro, - LanguageModelProvider::Anthropic, - "claude-3-opus", - false, - ), - ]; - - for (plan, provider, model, expected_access) in test_cases { - let claims = LlmTokenClaims { - plan, - ..Default::default() - }; - - let result = - authorize_access_to_language_model(&config, &claims, Some("US"), provider, model); - - if expected_access { - assert!( - result.is_ok(), - "Expected access to be granted for plan {:?}, provider {:?}, model {}", - plan, - provider, - model - ); - } else { - let error = result.expect_err(&format!( - "Expected access to be denied for plan {:?}, provider {:?}, model {}", - plan, provider, model - )); - let response = error.into_response(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - } - } - - #[gpui::test] - async fn test_authorize_access_to_language_model_for_staff() { - let config = Config::test(); - - let claims = LlmTokenClaims { - is_staff: true, - ..Default::default() - }; - - // Staff should have access to all models - let test_cases = vec![ - (LanguageModelProvider::Anthropic, "claude-3-5-sonnet"), - (LanguageModelProvider::Anthropic, "claude-2"), - (LanguageModelProvider::Anthropic, "claude-123-agi"), - (LanguageModelProvider::OpenAi, "gpt-4"), - (LanguageModelProvider::Google, "gemini-pro"), - ]; - - for (provider, model) in test_cases { - let result = - authorize_access_to_language_model(&config, &claims, Some("US"), provider, model); - - assert!( - result.is_ok(), - "Expected staff to have access to provider {:?}, model {}", - provider, - model - ); - } - } -} diff --git a/crates/collab/src/llm/db.rs b/crates/collab/src/llm/db.rs index 6a46184171..f56e9e61e3 100644 --- a/crates/collab/src/llm/db.rs +++ b/crates/collab/src/llm/db.rs @@ -20,7 +20,6 @@ use std::future::Future; use std::sync::Arc; use anyhow::anyhow; -pub use queries::usages::{ActiveUserCount, TokenUsage}; pub use sea_orm::ConnectOptions; use sea_orm::prelude::*; use sea_orm::{ diff --git a/crates/collab/src/llm/db/queries.rs b/crates/collab/src/llm/db/queries.rs index 79a17999b7..4a4a10fb51 100644 --- a/crates/collab/src/llm/db/queries.rs +++ b/crates/collab/src/llm/db/queries.rs @@ -2,5 +2,4 @@ use super::*; pub mod billing_events; pub mod providers; -pub mod revoked_access_tokens; pub mod usages; diff --git a/crates/collab/src/llm/db/queries/revoked_access_tokens.rs b/crates/collab/src/llm/db/queries/revoked_access_tokens.rs deleted file mode 100644 index 31d70192a0..0000000000 --- a/crates/collab/src/llm/db/queries/revoked_access_tokens.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::*; - -impl LlmDatabase { - /// Returns whether the access token with the given `jti` has been revoked. - pub async fn is_access_token_revoked(&self, jti: &str) -> Result { - self.transaction(|tx| async move { - Ok(revoked_access_token::Entity::find() - .filter(revoked_access_token::Column::Jti.eq(jti)) - .one(&*tx) - .await? - .is_some()) - }) - .await - } -} diff --git a/crates/collab/src/llm/db/queries/usages.rs b/crates/collab/src/llm/db/queries/usages.rs index 3dee5a41f6..6313e7572c 100644 --- a/crates/collab/src/llm/db/queries/usages.rs +++ b/crates/collab/src/llm/db/queries/usages.rs @@ -1,56 +1,12 @@ use crate::db::UserId; use crate::llm::Cents; -use chrono::{Datelike, Duration}; +use chrono::Datelike; use futures::StreamExt as _; -use rpc::LanguageModelProvider; -use sea_orm::QuerySelect; -use std::{iter, str::FromStr}; +use std::str::FromStr; use strum::IntoEnumIterator as _; use super::*; -#[derive(Debug, PartialEq, Clone, Copy, Default, serde::Serialize)] -pub struct TokenUsage { - pub input: usize, - pub input_cache_creation: usize, - pub input_cache_read: usize, - pub output: usize, -} - -impl TokenUsage { - pub fn total(&self) -> usize { - self.input + self.input_cache_creation + self.input_cache_read + self.output - } -} - -#[derive(Debug, PartialEq, Clone, Copy, serde::Serialize)] -pub struct Usage { - pub requests_this_minute: usize, - pub tokens_this_minute: usize, - pub input_tokens_this_minute: usize, - pub output_tokens_this_minute: usize, - pub tokens_this_day: usize, - pub tokens_this_month: TokenUsage, - pub spending_this_month: Cents, - pub lifetime_spending: Cents, -} - -#[derive(Debug, PartialEq, Clone)] -pub struct ApplicationWideUsage { - pub provider: LanguageModelProvider, - pub model: String, - pub requests_this_minute: usize, - pub tokens_this_minute: usize, - pub input_tokens_this_minute: usize, - pub output_tokens_this_minute: usize, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct ActiveUserCount { - pub users_in_recent_minutes: usize, - pub users_in_recent_days: usize, -} - impl LlmDatabase { pub async fn initialize_usage_measures(&mut self) -> Result<()> { let all_measures = self @@ -90,100 +46,6 @@ impl LlmDatabase { Ok(()) } - pub async fn get_application_wide_usages_by_model( - &self, - now: DateTimeUtc, - ) -> Result> { - self.transaction(|tx| async move { - let past_minute = now - Duration::minutes(1); - let requests_per_minute = self.usage_measure_ids[&UsageMeasure::RequestsPerMinute]; - let tokens_per_minute = self.usage_measure_ids[&UsageMeasure::TokensPerMinute]; - let input_tokens_per_minute = - self.usage_measure_ids[&UsageMeasure::InputTokensPerMinute]; - let output_tokens_per_minute = - self.usage_measure_ids[&UsageMeasure::OutputTokensPerMinute]; - - let mut results = Vec::new(); - for ((provider, model_name), model) in self.models.iter() { - let mut usages = usage::Entity::find() - .filter( - usage::Column::Timestamp - .gte(past_minute.naive_utc()) - .and(usage::Column::IsStaff.eq(false)) - .and(usage::Column::ModelId.eq(model.id)) - .and( - usage::Column::MeasureId - .eq(requests_per_minute) - .or(usage::Column::MeasureId.eq(tokens_per_minute)), - ), - ) - .stream(&*tx) - .await?; - - let mut requests_this_minute = 0; - let mut tokens_this_minute = 0; - let mut input_tokens_this_minute = 0; - let mut output_tokens_this_minute = 0; - while let Some(usage) = usages.next().await { - let usage = usage?; - if usage.measure_id == requests_per_minute { - requests_this_minute += Self::get_live_buckets( - &usage, - now.naive_utc(), - UsageMeasure::RequestsPerMinute, - ) - .0 - .iter() - .copied() - .sum::() as usize; - } else if usage.measure_id == tokens_per_minute { - tokens_this_minute += Self::get_live_buckets( - &usage, - now.naive_utc(), - UsageMeasure::TokensPerMinute, - ) - .0 - .iter() - .copied() - .sum::() as usize; - } else if usage.measure_id == input_tokens_per_minute { - input_tokens_this_minute += Self::get_live_buckets( - &usage, - now.naive_utc(), - UsageMeasure::InputTokensPerMinute, - ) - .0 - .iter() - .copied() - .sum::() as usize; - } else if usage.measure_id == output_tokens_per_minute { - output_tokens_this_minute += Self::get_live_buckets( - &usage, - now.naive_utc(), - UsageMeasure::OutputTokensPerMinute, - ) - .0 - .iter() - .copied() - .sum::() as usize; - } - } - - results.push(ApplicationWideUsage { - provider: *provider, - model: model_name.clone(), - requests_this_minute, - tokens_this_minute, - input_tokens_this_minute, - output_tokens_this_minute, - }) - } - - Ok(results) - }) - .await - } - pub async fn get_user_spending_for_month( &self, user_id: UserId, @@ -223,499 +85,6 @@ impl LlmDatabase { }) .await } - - pub async fn get_usage( - &self, - user_id: UserId, - provider: LanguageModelProvider, - model_name: &str, - now: DateTimeUtc, - ) -> Result { - self.transaction(|tx| async move { - let model = self - .models - .get(&(provider, model_name.to_string())) - .ok_or_else(|| anyhow!("unknown model {provider}:{model_name}"))?; - - let usages = usage::Entity::find() - .filter( - usage::Column::UserId - .eq(user_id) - .and(usage::Column::ModelId.eq(model.id)), - ) - .all(&*tx) - .await?; - - let month = now.date_naive().month() as i32; - let year = now.date_naive().year(); - let monthly_usage = monthly_usage::Entity::find() - .filter( - monthly_usage::Column::UserId - .eq(user_id) - .and(monthly_usage::Column::ModelId.eq(model.id)) - .and(monthly_usage::Column::Month.eq(month)) - .and(monthly_usage::Column::Year.eq(year)), - ) - .one(&*tx) - .await?; - let lifetime_usage = lifetime_usage::Entity::find() - .filter( - lifetime_usage::Column::UserId - .eq(user_id) - .and(lifetime_usage::Column::ModelId.eq(model.id)), - ) - .one(&*tx) - .await?; - - let requests_this_minute = - self.get_usage_for_measure(&usages, now, UsageMeasure::RequestsPerMinute)?; - let tokens_this_minute = - self.get_usage_for_measure(&usages, now, UsageMeasure::TokensPerMinute)?; - let input_tokens_this_minute = - self.get_usage_for_measure(&usages, now, UsageMeasure::InputTokensPerMinute)?; - let output_tokens_this_minute = - self.get_usage_for_measure(&usages, now, UsageMeasure::OutputTokensPerMinute)?; - let tokens_this_day = - self.get_usage_for_measure(&usages, now, UsageMeasure::TokensPerDay)?; - let spending_this_month = if let Some(monthly_usage) = &monthly_usage { - calculate_spending( - model, - monthly_usage.input_tokens as usize, - monthly_usage.cache_creation_input_tokens as usize, - monthly_usage.cache_read_input_tokens as usize, - monthly_usage.output_tokens as usize, - ) - } else { - Cents::ZERO - }; - let lifetime_spending = if let Some(lifetime_usage) = &lifetime_usage { - calculate_spending( - model, - lifetime_usage.input_tokens as usize, - lifetime_usage.cache_creation_input_tokens as usize, - lifetime_usage.cache_read_input_tokens as usize, - lifetime_usage.output_tokens as usize, - ) - } else { - Cents::ZERO - }; - - Ok(Usage { - requests_this_minute, - tokens_this_minute, - input_tokens_this_minute, - output_tokens_this_minute, - tokens_this_day, - tokens_this_month: TokenUsage { - input: monthly_usage - .as_ref() - .map_or(0, |usage| usage.input_tokens as usize), - input_cache_creation: monthly_usage - .as_ref() - .map_or(0, |usage| usage.cache_creation_input_tokens as usize), - input_cache_read: monthly_usage - .as_ref() - .map_or(0, |usage| usage.cache_read_input_tokens as usize), - output: monthly_usage - .as_ref() - .map_or(0, |usage| usage.output_tokens as usize), - }, - spending_this_month, - lifetime_spending, - }) - }) - .await - } - - pub async fn record_usage( - &self, - user_id: UserId, - is_staff: bool, - provider: LanguageModelProvider, - model_name: &str, - tokens: TokenUsage, - has_llm_subscription: bool, - max_monthly_spend: Cents, - free_tier_monthly_spending_limit: Cents, - now: DateTimeUtc, - ) -> Result { - self.transaction(|tx| async move { - let model = self.model(provider, model_name)?; - - let usages = usage::Entity::find() - .filter( - usage::Column::UserId - .eq(user_id) - .and(usage::Column::ModelId.eq(model.id)), - ) - .all(&*tx) - .await?; - - let requests_this_minute = self - .update_usage_for_measure( - user_id, - is_staff, - model.id, - &usages, - UsageMeasure::RequestsPerMinute, - now, - 1, - &tx, - ) - .await?; - let tokens_this_minute = self - .update_usage_for_measure( - user_id, - is_staff, - model.id, - &usages, - UsageMeasure::TokensPerMinute, - now, - tokens.total(), - &tx, - ) - .await?; - let input_tokens_this_minute = self - .update_usage_for_measure( - user_id, - is_staff, - model.id, - &usages, - UsageMeasure::InputTokensPerMinute, - now, - // Cache read input tokens are not counted for the purposes of rate limits (but they are still billed). - tokens.input + tokens.input_cache_creation, - &tx, - ) - .await?; - let output_tokens_this_minute = self - .update_usage_for_measure( - user_id, - is_staff, - model.id, - &usages, - UsageMeasure::OutputTokensPerMinute, - now, - tokens.output, - &tx, - ) - .await?; - let tokens_this_day = self - .update_usage_for_measure( - user_id, - is_staff, - model.id, - &usages, - UsageMeasure::TokensPerDay, - now, - tokens.total(), - &tx, - ) - .await?; - - let month = now.date_naive().month() as i32; - let year = now.date_naive().year(); - - // Update monthly usage - let monthly_usage = monthly_usage::Entity::find() - .filter( - monthly_usage::Column::UserId - .eq(user_id) - .and(monthly_usage::Column::ModelId.eq(model.id)) - .and(monthly_usage::Column::Month.eq(month)) - .and(monthly_usage::Column::Year.eq(year)), - ) - .one(&*tx) - .await?; - - let monthly_usage = match monthly_usage { - Some(usage) => { - monthly_usage::Entity::update(monthly_usage::ActiveModel { - id: ActiveValue::unchanged(usage.id), - input_tokens: ActiveValue::set(usage.input_tokens + tokens.input as i64), - cache_creation_input_tokens: ActiveValue::set( - usage.cache_creation_input_tokens + tokens.input_cache_creation as i64, - ), - cache_read_input_tokens: ActiveValue::set( - usage.cache_read_input_tokens + tokens.input_cache_read as i64, - ), - output_tokens: ActiveValue::set(usage.output_tokens + tokens.output as i64), - ..Default::default() - }) - .exec(&*tx) - .await? - } - None => { - monthly_usage::ActiveModel { - user_id: ActiveValue::set(user_id), - model_id: ActiveValue::set(model.id), - month: ActiveValue::set(month), - year: ActiveValue::set(year), - input_tokens: ActiveValue::set(tokens.input as i64), - cache_creation_input_tokens: ActiveValue::set( - tokens.input_cache_creation as i64, - ), - cache_read_input_tokens: ActiveValue::set(tokens.input_cache_read as i64), - output_tokens: ActiveValue::set(tokens.output as i64), - ..Default::default() - } - .insert(&*tx) - .await? - } - }; - - let spending_this_month = calculate_spending( - model, - monthly_usage.input_tokens as usize, - monthly_usage.cache_creation_input_tokens as usize, - monthly_usage.cache_read_input_tokens as usize, - monthly_usage.output_tokens as usize, - ); - - if !is_staff - && spending_this_month > free_tier_monthly_spending_limit - && has_llm_subscription - && (spending_this_month - free_tier_monthly_spending_limit) <= max_monthly_spend - { - billing_event::ActiveModel { - id: ActiveValue::not_set(), - idempotency_key: ActiveValue::not_set(), - user_id: ActiveValue::set(user_id), - model_id: ActiveValue::set(model.id), - input_tokens: ActiveValue::set(tokens.input as i64), - input_cache_creation_tokens: ActiveValue::set( - tokens.input_cache_creation as i64, - ), - input_cache_read_tokens: ActiveValue::set(tokens.input_cache_read as i64), - output_tokens: ActiveValue::set(tokens.output as i64), - } - .insert(&*tx) - .await?; - } - - // Update lifetime usage - let lifetime_usage = lifetime_usage::Entity::find() - .filter( - lifetime_usage::Column::UserId - .eq(user_id) - .and(lifetime_usage::Column::ModelId.eq(model.id)), - ) - .one(&*tx) - .await?; - - let lifetime_usage = match lifetime_usage { - Some(usage) => { - lifetime_usage::Entity::update(lifetime_usage::ActiveModel { - id: ActiveValue::unchanged(usage.id), - input_tokens: ActiveValue::set(usage.input_tokens + tokens.input as i64), - cache_creation_input_tokens: ActiveValue::set( - usage.cache_creation_input_tokens + tokens.input_cache_creation as i64, - ), - cache_read_input_tokens: ActiveValue::set( - usage.cache_read_input_tokens + tokens.input_cache_read as i64, - ), - output_tokens: ActiveValue::set(usage.output_tokens + tokens.output as i64), - ..Default::default() - }) - .exec(&*tx) - .await? - } - None => { - lifetime_usage::ActiveModel { - user_id: ActiveValue::set(user_id), - model_id: ActiveValue::set(model.id), - input_tokens: ActiveValue::set(tokens.input as i64), - cache_creation_input_tokens: ActiveValue::set( - tokens.input_cache_creation as i64, - ), - cache_read_input_tokens: ActiveValue::set(tokens.input_cache_read as i64), - output_tokens: ActiveValue::set(tokens.output as i64), - ..Default::default() - } - .insert(&*tx) - .await? - } - }; - - let lifetime_spending = calculate_spending( - model, - lifetime_usage.input_tokens as usize, - lifetime_usage.cache_creation_input_tokens as usize, - lifetime_usage.cache_read_input_tokens as usize, - lifetime_usage.output_tokens as usize, - ); - - Ok(Usage { - requests_this_minute, - tokens_this_minute, - input_tokens_this_minute, - output_tokens_this_minute, - tokens_this_day, - tokens_this_month: TokenUsage { - input: monthly_usage.input_tokens as usize, - input_cache_creation: monthly_usage.cache_creation_input_tokens as usize, - input_cache_read: monthly_usage.cache_read_input_tokens as usize, - output: monthly_usage.output_tokens as usize, - }, - spending_this_month, - lifetime_spending, - }) - }) - .await - } - - /// Returns the active user count for the specified model. - pub async fn get_active_user_count( - &self, - provider: LanguageModelProvider, - model_name: &str, - now: DateTimeUtc, - ) -> Result { - self.transaction(|tx| async move { - let minute_since = now - Duration::minutes(5); - let day_since = now - Duration::days(5); - - let model = self - .models - .get(&(provider, model_name.to_string())) - .ok_or_else(|| anyhow!("unknown model {provider}:{model_name}"))?; - - let tokens_per_minute = self.usage_measure_ids[&UsageMeasure::TokensPerMinute]; - - let users_in_recent_minutes = usage::Entity::find() - .filter( - usage::Column::ModelId - .eq(model.id) - .and(usage::Column::MeasureId.eq(tokens_per_minute)) - .and(usage::Column::Timestamp.gte(minute_since.naive_utc())) - .and(usage::Column::IsStaff.eq(false)), - ) - .select_only() - .column(usage::Column::UserId) - .group_by(usage::Column::UserId) - .count(&*tx) - .await? as usize; - - let users_in_recent_days = usage::Entity::find() - .filter( - usage::Column::ModelId - .eq(model.id) - .and(usage::Column::MeasureId.eq(tokens_per_minute)) - .and(usage::Column::Timestamp.gte(day_since.naive_utc())) - .and(usage::Column::IsStaff.eq(false)), - ) - .select_only() - .column(usage::Column::UserId) - .group_by(usage::Column::UserId) - .count(&*tx) - .await? as usize; - - Ok(ActiveUserCount { - users_in_recent_minutes, - users_in_recent_days, - }) - }) - .await - } - - async fn update_usage_for_measure( - &self, - user_id: UserId, - is_staff: bool, - model_id: ModelId, - usages: &[usage::Model], - usage_measure: UsageMeasure, - now: DateTimeUtc, - usage_to_add: usize, - tx: &DatabaseTransaction, - ) -> Result { - let now = now.naive_utc(); - let measure_id = *self - .usage_measure_ids - .get(&usage_measure) - .ok_or_else(|| anyhow!("usage measure {usage_measure} not found"))?; - - let mut id = None; - let mut timestamp = now; - let mut buckets = vec![0_i64]; - - if let Some(old_usage) = usages.iter().find(|usage| usage.measure_id == measure_id) { - id = Some(old_usage.id); - let (live_buckets, buckets_since) = - Self::get_live_buckets(old_usage, now, usage_measure); - if !live_buckets.is_empty() { - buckets.clear(); - buckets.extend_from_slice(live_buckets); - buckets.extend(iter::repeat(0).take(buckets_since)); - timestamp = - old_usage.timestamp + (usage_measure.bucket_duration() * buckets_since as i32); - } - } - - *buckets.last_mut().unwrap() += usage_to_add as i64; - let total_usage = buckets.iter().sum::() as usize; - - let mut model = usage::ActiveModel { - user_id: ActiveValue::set(user_id), - is_staff: ActiveValue::set(is_staff), - model_id: ActiveValue::set(model_id), - measure_id: ActiveValue::set(measure_id), - timestamp: ActiveValue::set(timestamp), - buckets: ActiveValue::set(buckets), - ..Default::default() - }; - - if let Some(id) = id { - model.id = ActiveValue::unchanged(id); - model.update(tx).await?; - } else { - usage::Entity::insert(model) - .exec_without_returning(tx) - .await?; - } - - Ok(total_usage) - } - - fn get_usage_for_measure( - &self, - usages: &[usage::Model], - now: DateTimeUtc, - usage_measure: UsageMeasure, - ) -> Result { - let now = now.naive_utc(); - let measure_id = *self - .usage_measure_ids - .get(&usage_measure) - .ok_or_else(|| anyhow!("usage measure {usage_measure} not found"))?; - let Some(usage) = usages.iter().find(|usage| usage.measure_id == measure_id) else { - return Ok(0); - }; - - let (live_buckets, _) = Self::get_live_buckets(usage, now, usage_measure); - Ok(live_buckets.iter().sum::() as _) - } - - fn get_live_buckets( - usage: &usage::Model, - now: chrono::NaiveDateTime, - measure: UsageMeasure, - ) -> (&[i64], usize) { - let seconds_since_usage = (now - usage.timestamp).num_seconds().max(0); - let buckets_since_usage = - seconds_since_usage as f32 / measure.bucket_duration().num_seconds() as f32; - let buckets_since_usage = buckets_since_usage.ceil() as usize; - let mut live_buckets = &[] as &[i64]; - if buckets_since_usage < measure.bucket_count() { - let expired_bucket_count = - (usage.buckets.len() + buckets_since_usage).saturating_sub(measure.bucket_count()); - live_buckets = &usage.buckets[expired_bucket_count..]; - while live_buckets.first() == Some(&0) { - live_buckets = &live_buckets[1..]; - } - } - (live_buckets, buckets_since_usage) - } } fn calculate_spending( @@ -741,32 +110,3 @@ fn calculate_spending( + output_token_cost; Cents::new(spending as u32) } - -const MINUTE_BUCKET_COUNT: usize = 12; -const DAY_BUCKET_COUNT: usize = 48; - -impl UsageMeasure { - fn bucket_count(&self) -> usize { - match self { - UsageMeasure::RequestsPerMinute => MINUTE_BUCKET_COUNT, - UsageMeasure::TokensPerMinute - | UsageMeasure::InputTokensPerMinute - | UsageMeasure::OutputTokensPerMinute => MINUTE_BUCKET_COUNT, - UsageMeasure::TokensPerDay => DAY_BUCKET_COUNT, - } - } - - fn total_duration(&self) -> Duration { - match self { - UsageMeasure::RequestsPerMinute => Duration::minutes(1), - UsageMeasure::TokensPerMinute - | UsageMeasure::InputTokensPerMinute - | UsageMeasure::OutputTokensPerMinute => Duration::minutes(1), - UsageMeasure::TokensPerDay => Duration::hours(24), - } - } - - fn bucket_duration(&self) -> Duration { - self.total_duration() / self.bucket_count() as i32 - } -} diff --git a/crates/collab/src/llm/db/tables.rs b/crates/collab/src/llm/db/tables.rs index 407c5c8fd0..5f2d357a87 100644 --- a/crates/collab/src/llm/db/tables.rs +++ b/crates/collab/src/llm/db/tables.rs @@ -1,8 +1,6 @@ pub mod billing_event; -pub mod lifetime_usage; pub mod model; pub mod monthly_usage; pub mod provider; -pub mod revoked_access_token; pub mod usage; pub mod usage_measure; diff --git a/crates/collab/src/llm/db/tables/lifetime_usage.rs b/crates/collab/src/llm/db/tables/lifetime_usage.rs deleted file mode 100644 index fc8354699b..0000000000 --- a/crates/collab/src/llm/db/tables/lifetime_usage.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::{db::UserId, llm::db::ModelId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "lifetime_usages")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub user_id: UserId, - pub model_id: ModelId, - pub input_tokens: i64, - pub cache_creation_input_tokens: i64, - pub cache_read_input_tokens: i64, - pub output_tokens: i64, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/llm/db/tables/revoked_access_token.rs b/crates/collab/src/llm/db/tables/revoked_access_token.rs deleted file mode 100644 index 364963be88..0000000000 --- a/crates/collab/src/llm/db/tables/revoked_access_token.rs +++ /dev/null @@ -1,19 +0,0 @@ -use chrono::NaiveDateTime; -use sea_orm::entity::prelude::*; - -use crate::llm::db::RevokedAccessTokenId; - -/// A revoked access token. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "revoked_access_tokens")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: RevokedAccessTokenId, - pub jti: String, - pub revoked_at: NaiveDateTime, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/llm/db/tests.rs b/crates/collab/src/llm/db/tests.rs index 59f92958c7..43a1b8b0d4 100644 --- a/crates/collab/src/llm/db/tests.rs +++ b/crates/collab/src/llm/db/tests.rs @@ -1,6 +1,4 @@ -mod billing_tests; mod provider_tests; -mod usage_tests; use gpui::BackgroundExecutor; use parking_lot::Mutex; diff --git a/crates/collab/src/llm/db/tests/billing_tests.rs b/crates/collab/src/llm/db/tests/billing_tests.rs deleted file mode 100644 index 3a95610bc2..0000000000 --- a/crates/collab/src/llm/db/tests/billing_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -use crate::{ - Cents, - db::UserId, - llm::{ - FREE_TIER_MONTHLY_SPENDING_LIMIT, - db::{LlmDatabase, TokenUsage, queries::providers::ModelParams}, - }, - test_llm_db, -}; -use chrono::{DateTime, Utc}; -use pretty_assertions::assert_eq; -use rpc::LanguageModelProvider; - -test_llm_db!( - test_billing_limit_exceeded, - test_billing_limit_exceeded_postgres -); - -async fn test_billing_limit_exceeded(db: &mut LlmDatabase) { - let provider = LanguageModelProvider::Anthropic; - let model = "fake-claude-limerick"; - const PRICE_PER_MILLION_INPUT_TOKENS: i32 = 5; - const PRICE_PER_MILLION_OUTPUT_TOKENS: i32 = 5; - - // Initialize the database and insert the model - db.initialize().await.unwrap(); - db.insert_models(&[ModelParams { - provider, - name: model.to_string(), - max_requests_per_minute: 5, - max_tokens_per_minute: 10_000, - max_tokens_per_day: 50_000, - price_per_million_input_tokens: PRICE_PER_MILLION_INPUT_TOKENS, - price_per_million_output_tokens: PRICE_PER_MILLION_OUTPUT_TOKENS, - }]) - .await - .unwrap(); - - // Set a fixed datetime for consistent testing - let now = DateTime::parse_from_rfc3339("2024-08-08T22:46:33Z") - .unwrap() - .with_timezone(&Utc); - - let user_id = UserId::from_proto(123); - - let max_monthly_spend = Cents::from_dollars(11); - - // Record usage that brings us close to the limit but doesn't exceed it - // Let's say we use $10.50 worth of tokens - let tokens_to_use = 210_000_000; // This will cost $10.50 at $0.05 per 1 million tokens - let usage = TokenUsage { - input: tokens_to_use, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }; - - // Verify that before we record any usage, there are 0 billing events - let billing_events = db.get_billing_events().await.unwrap(); - assert_eq!(billing_events.len(), 0); - - db.record_usage( - user_id, - false, - provider, - model, - usage, - true, - max_monthly_spend, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - // Verify the recorded usage and spending - let recorded_usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - // Verify that we exceeded the free tier usage - assert_eq!(recorded_usage.spending_this_month, Cents::new(1050)); - assert!(recorded_usage.spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT); - - // Verify that there is one `billing_event` record - let billing_events = db.get_billing_events().await.unwrap(); - assert_eq!(billing_events.len(), 1); - - let (billing_event, _model) = &billing_events[0]; - assert_eq!(billing_event.user_id, user_id); - assert_eq!(billing_event.input_tokens, tokens_to_use as i64); - assert_eq!(billing_event.input_cache_creation_tokens, 0); - assert_eq!(billing_event.input_cache_read_tokens, 0); - assert_eq!(billing_event.output_tokens, 0); - - // Record usage that puts us at $20.50 - let usage_2 = TokenUsage { - input: 200_000_000, // This will cost $10 more, pushing us from $10.50 to $20.50, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }; - db.record_usage( - user_id, - false, - provider, - model, - usage_2, - true, - max_monthly_spend, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - // Verify the updated usage and spending - let updated_usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!(updated_usage.spending_this_month, Cents::new(2050)); - - // Verify that there are now two billing events - let billing_events = db.get_billing_events().await.unwrap(); - assert_eq!(billing_events.len(), 2); - - let tokens_to_exceed = 20_000_000; // This will cost $1.00 more, pushing us from $20.50 to $21.50, which is over the $11 monthly maximum limit - let usage_exceeding = TokenUsage { - input: tokens_to_exceed, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }; - - // This should still create a billing event as it's the first request that exceeds the limit - db.record_usage( - user_id, - false, - provider, - model, - usage_exceeding, - true, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - max_monthly_spend, - now, - ) - .await - .unwrap(); - // Verify the updated usage and spending - let updated_usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!(updated_usage.spending_this_month, Cents::new(2150)); - - // Verify that we never exceed the user max spending for the user - // and avoid charging them. - let billing_events = db.get_billing_events().await.unwrap(); - assert_eq!(billing_events.len(), 2); -} diff --git a/crates/collab/src/llm/db/tests/usage_tests.rs b/crates/collab/src/llm/db/tests/usage_tests.rs deleted file mode 100644 index 0a4ef7f4cf..0000000000 --- a/crates/collab/src/llm/db/tests/usage_tests.rs +++ /dev/null @@ -1,306 +0,0 @@ -use crate::llm::FREE_TIER_MONTHLY_SPENDING_LIMIT; -use crate::{ - Cents, - db::UserId, - llm::db::{ - LlmDatabase, TokenUsage, - queries::{providers::ModelParams, usages::Usage}, - }, - test_llm_db, -}; -use chrono::{DateTime, Duration, Utc}; -use pretty_assertions::assert_eq; -use rpc::LanguageModelProvider; - -test_llm_db!(test_tracking_usage, test_tracking_usage_postgres); - -async fn test_tracking_usage(db: &mut LlmDatabase) { - let provider = LanguageModelProvider::Anthropic; - let model = "claude-3-5-sonnet"; - - db.initialize().await.unwrap(); - db.insert_models(&[ModelParams { - provider, - name: model.to_string(), - max_requests_per_minute: 5, - max_tokens_per_minute: 10_000, - max_tokens_per_day: 50_000, - price_per_million_input_tokens: 50, - price_per_million_output_tokens: 50, - }]) - .await - .unwrap(); - - // We're using a fixed datetime to prevent flakiness based on the clock. - let t0 = DateTime::parse_from_rfc3339("2024-08-08T22:46:33Z") - .unwrap() - .with_timezone(&Utc); - let user_id = UserId::from_proto(123); - - let now = t0; - db.record_usage( - user_id, - false, - provider, - model, - TokenUsage { - input: 1000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - false, - Cents::ZERO, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - let now = t0 + Duration::seconds(10); - db.record_usage( - user_id, - false, - provider, - model, - TokenUsage { - input: 2000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - false, - Cents::ZERO, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 2, - tokens_this_minute: 3000, - input_tokens_this_minute: 3000, - output_tokens_this_minute: 0, - tokens_this_day: 3000, - tokens_this_month: TokenUsage { - input: 3000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); - - let now = t0 + Duration::seconds(60); - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 1, - tokens_this_minute: 2000, - input_tokens_this_minute: 2000, - output_tokens_this_minute: 0, - tokens_this_day: 3000, - tokens_this_month: TokenUsage { - input: 3000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); - - let now = t0 + Duration::seconds(60); - db.record_usage( - user_id, - false, - provider, - model, - TokenUsage { - input: 3000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - false, - Cents::ZERO, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 2, - tokens_this_minute: 5000, - input_tokens_this_minute: 5000, - output_tokens_this_minute: 0, - tokens_this_day: 6000, - tokens_this_month: TokenUsage { - input: 6000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); - - let t1 = t0 + Duration::hours(24); - let now = t1; - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 0, - tokens_this_minute: 0, - input_tokens_this_minute: 0, - output_tokens_this_minute: 0, - tokens_this_day: 5000, - tokens_this_month: TokenUsage { - input: 6000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); - - db.record_usage( - user_id, - false, - provider, - model, - TokenUsage { - input: 4000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - false, - Cents::ZERO, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 1, - tokens_this_minute: 4000, - input_tokens_this_minute: 4000, - output_tokens_this_minute: 0, - tokens_this_day: 9000, - tokens_this_month: TokenUsage { - input: 10000, - input_cache_creation: 0, - input_cache_read: 0, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); - - // We're using a fixed datetime to prevent flakiness based on the clock. - let now = DateTime::parse_from_rfc3339("2024-10-08T22:15:58Z") - .unwrap() - .with_timezone(&Utc); - - // Test cache creation input tokens - db.record_usage( - user_id, - false, - provider, - model, - TokenUsage { - input: 1000, - input_cache_creation: 500, - input_cache_read: 0, - output: 0, - }, - false, - Cents::ZERO, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 1, - tokens_this_minute: 1500, - input_tokens_this_minute: 1500, - output_tokens_this_minute: 0, - tokens_this_day: 1500, - tokens_this_month: TokenUsage { - input: 1000, - input_cache_creation: 500, - input_cache_read: 0, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); - - // Test cache read input tokens - db.record_usage( - user_id, - false, - provider, - model, - TokenUsage { - input: 1000, - input_cache_creation: 0, - input_cache_read: 300, - output: 0, - }, - false, - Cents::ZERO, - FREE_TIER_MONTHLY_SPENDING_LIMIT, - now, - ) - .await - .unwrap(); - - let usage = db.get_usage(user_id, provider, model, now).await.unwrap(); - assert_eq!( - usage, - Usage { - requests_this_minute: 2, - tokens_this_minute: 2800, - input_tokens_this_minute: 2500, - output_tokens_this_minute: 0, - tokens_this_day: 2800, - tokens_this_month: TokenUsage { - input: 2000, - input_cache_creation: 500, - input_cache_read: 300, - output: 0, - }, - spending_this_month: Cents::ZERO, - lifetime_spending: Cents::ZERO, - } - ); -} diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 30dab40cce..8f850ee847 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -9,14 +9,14 @@ use axum::{ use collab::api::CloudflareIpCountryHeader; use collab::api::billing::sync_llm_usage_with_stripe_periodically; -use collab::llm::{db::LlmDatabase, log_usage_periodically}; +use collab::llm::db::LlmDatabase; use collab::migrations::run_database_migrations; use collab::user_backfiller::spawn_user_backfiller; use collab::{ AppState, Config, RateLimiter, Result, api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor, rpc::ResultExt, }; -use collab::{ServiceMode, api::billing::poll_stripe_events_periodically, llm::LlmState}; +use collab::{ServiceMode, api::billing::poll_stripe_events_periodically}; use db::Database; use std::{ env::args, @@ -74,11 +74,10 @@ async fn main() -> Result<()> { let mode = match args.next().as_deref() { Some("collab") => ServiceMode::Collab, Some("api") => ServiceMode::Api, - Some("llm") => ServiceMode::Llm, Some("all") => ServiceMode::All, _ => { return Err(anyhow!( - "usage: collab >" + "usage: collab >" ))?; } }; @@ -97,20 +96,9 @@ async fn main() -> Result<()> { let mut on_shutdown = None; - if mode.is_llm() { - setup_llm_database(&config).await?; - - let state = LlmState::new(config.clone(), Executor::Production).await?; - - log_usage_periodically(state.clone()); - - app = app - .merge(collab::llm::routes()) - .layer(Extension(state.clone())); - } - if mode.is_collab() || mode.is_api() { setup_app_database(&config).await?; + setup_llm_database(&config).await?; let state = AppState::new(config, Executor::Production).await?; @@ -336,18 +324,11 @@ async fn handle_root(Extension(mode): Extension) -> String { format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown")) } -async fn handle_liveness_probe( - app_state: Option>>, - llm_state: Option>>, -) -> Result { +async fn handle_liveness_probe(app_state: Option>>) -> Result { if let Some(state) = app_state { state.db.get_all_users(0, 1).await?; } - if let Some(llm_state) = llm_state { - llm_state.db.list_providers().await?; - } - Ok("ok".to_string()) } From b794919842c00662f9376dee0154909b84795f20 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 15 Apr 2025 00:54:25 -0400 Subject: [PATCH 66/75] Add contents_tool (#28738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a combination of the "read file" and "list directory contents" tools as part of a push to reduce our quantity of builtin tools by combining some of them. The functionality is all there for this tool, although there's room for improvement on the visuals side: it currently always shows the same icon and always says "Read" - so you can't tell at a glance when it's reading a directory vs an individual file. Changing this will require a change to the `Tool` trait, which can be in a separate PR. (FYI @danilo-leal!) Screenshot 2025-04-14 at 11 56 27 PM Release Notes: - Added `contents` tool --- assets/settings/default.json | 2 + crates/assistant_tools/src/assistant_tools.rs | 3 + crates/assistant_tools/src/contents_tool.rs | 239 ++++++++++++++++++ .../src/contents_tool/description.md | 9 + 4 files changed, 253 insertions(+) create mode 100644 crates/assistant_tools/src/contents_tool.rs create mode 100644 crates/assistant_tools/src/contents_tool/description.md diff --git a/assets/settings/default.json b/assets/settings/default.json index df1a4a01af..5c6335099b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -644,6 +644,7 @@ // We don't know which of the context server tools are safe for the "Ask" profile, so we don't enable them by default. // "enable_all_context_servers": true, "tools": { + "contents": true, "diagnostics": true, "fetch": true, "list_directory": false, @@ -662,6 +663,7 @@ "batch_tool": true, "code_actions": true, "code_symbols": true, + "contents": true, "copy_path": false, "create_file": true, "delete_path": false, diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 76e8b8670b..3016f5412f 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -1,6 +1,7 @@ mod batch_tool; mod code_action_tool; mod code_symbols_tool; +mod contents_tool; mod copy_path_tool; mod create_directory_tool; mod create_file_tool; @@ -33,6 +34,7 @@ use move_path_tool::MovePathTool; use crate::batch_tool::BatchTool; use crate::code_action_tool::CodeActionTool; use crate::code_symbols_tool::CodeSymbolsTool; +use crate::contents_tool::ContentsTool; use crate::create_directory_tool::CreateDirectoryTool; use crate::create_file_tool::CreateFileTool; use crate::delete_path_tool::DeletePathTool; @@ -69,6 +71,7 @@ pub fn init(http_client: Arc, cx: &mut App) { registry.register_tool(NowTool); registry.register_tool(OpenTool); registry.register_tool(CodeSymbolsTool); + registry.register_tool(ContentsTool); registry.register_tool(PathSearchTool); registry.register_tool(ReadFileTool); registry.register_tool(RegexSearchTool); diff --git a/crates/assistant_tools/src/contents_tool.rs b/crates/assistant_tools/src/contents_tool.rs new file mode 100644 index 0000000000..be7c4927cb --- /dev/null +++ b/crates/assistant_tools/src/contents_tool.rs @@ -0,0 +1,239 @@ +use std::sync::Arc; + +use crate::{code_symbols_tool::file_outline, schema::json_schema_for}; +use anyhow::{Result, anyhow}; +use assistant_tool::{ActionLog, Tool}; +use gpui::{App, Entity, Task}; +use itertools::Itertools; +use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{fmt::Write, path::Path}; +use ui::IconName; +use util::markdown::MarkdownString; + +/// If the model requests to read a file whose size exceeds this, then +/// the tool will return the file's symbol outline instead of its contents, +/// and suggest trying again using line ranges from the outline. +const MAX_FILE_SIZE_TO_READ: usize = 16384; + +/// If the model requests to list the entries in a directory with more +/// entries than this, then the tool will return a subset of the entries +/// and suggest trying again. +const MAX_DIR_ENTRIES: usize = 1024; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ContentsToolInput { + /// The relative path of the file or directory to access. + /// + /// This path should never be absolute, and the first component + /// of the path should always be a root directory in a project. + /// + /// + /// If the project has the following root directories: + /// + /// - directory1 + /// - directory2 + /// + /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`. + /// If you want to list contents in the directory `directory2/subfolder`, you should use the path `directory2/subfolder`. + /// + pub path: String, + + /// Optional position (1-based index) to start reading on, if you want to read a subset of the contents. + /// When reading a file, this refers to a line number in the file (e.g. 1 is the first line). + /// When reading a directory, this refers to the number of the directory entry (e.g. 1 is the first entry). + /// + /// Defaults to 1. + pub start: Option, + + /// Optional position (1-based index) to end reading on, if you want to read a subset of the contents. + /// When reading a file, this refers to a line number in the file (e.g. 1 is the first line). + /// When reading a directory, this refers to the number of the directory entry (e.g. 1 is the first entry). + /// + /// Defaults to reading until the end of the file or directory. + pub end: Option, +} + +pub struct ContentsTool; + +impl Tool for ContentsTool { + fn name(&self) -> String { + "contents".into() + } + + fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + false + } + + fn description(&self) -> String { + include_str!("./contents_tool/description.md").into() + } + + fn icon(&self) -> IconName { + IconName::FileSearch + } + + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { + json_schema_for::(format) + } + + fn ui_text(&self, input: &serde_json::Value) -> String { + match serde_json::from_value::(input.clone()) { + Ok(input) => { + let path = MarkdownString::inline_code(&input.path); + + match (input.start, input.end) { + (Some(start), None) => format!("Read {path} (from line {start})"), + (Some(start), Some(end)) => { + format!("Read {path} (lines {start}-{end})") + } + _ => format!("Read {path}"), + } + } + Err(_) => "Read file or directory".to_string(), + } + } + + fn run( + self: Arc, + input: serde_json::Value, + _messages: &[LanguageModelRequestMessage], + project: Entity, + action_log: Entity, + cx: &mut App, + ) -> Task> { + let input = match serde_json::from_value::(input) { + Ok(input) => input, + Err(err) => return Task::ready(Err(anyhow!(err))), + }; + + // Sometimes models will return these even though we tell it to give a path and not a glob. + // When this happens, just list the root worktree directories. + if matches!(input.path.as_str(), "." | "" | "./" | "*") { + let output = project + .read(cx) + .worktrees(cx) + .filter_map(|worktree| { + worktree.read(cx).root_entry().and_then(|entry| { + if entry.is_dir() { + entry.path.to_str() + } else { + None + } + }) + }) + .collect::>() + .join("\n"); + + return Task::ready(Ok(output)); + } + + let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else { + return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))); + }; + + let Some(worktree) = project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!("Worktree not found"))); + }; + let worktree = worktree.read(cx); + + let Some(entry) = worktree.entry_for_path(&project_path.path) else { + return Task::ready(Err(anyhow!("Path not found: {}", input.path))); + }; + + // If it's a directory, list its contents + if entry.is_dir() { + let mut output = String::new(); + let start_index = input + .start + .map(|line| (line as usize).saturating_sub(1)) + .unwrap_or(0); + let end_index = input + .end + .map(|line| (line as usize).saturating_sub(1)) + .unwrap_or(MAX_DIR_ENTRIES); + let mut skipped = 0; + + for (index, entry) in worktree.child_entries(&project_path.path).enumerate() { + if index >= start_index && index <= end_index { + writeln!( + output, + "{}", + Path::new(worktree.root_name()).join(&entry.path).display(), + ) + .unwrap(); + } else { + skipped += 1; + } + } + + if output.is_empty() { + output.push_str(&input.path); + output.push_str(" is empty."); + } + + if skipped > 0 { + write!( + output, + "\n\nNote: Skipped {skipped} entries. Adjust start and end to see other entries.", + ).ok(); + } + + Task::ready(Ok(output)) + } else { + // It's a file, so read its contents + let file_path = input.path.clone(); + cx.spawn(async move |cx| { + let buffer = cx + .update(|cx| { + project.update(cx, |project, cx| project.open_buffer(project_path, cx)) + })? + .await?; + + if input.start.is_some() || input.end.is_some() { + let result = buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + let start = input.start.unwrap_or(1); + let lines = text.split('\n').skip(start as usize - 1); + if let Some(end) = input.end { + let count = end.saturating_sub(start).max(1); // Ensure at least 1 line + Itertools::intersperse(lines.take(count as usize), "\n").collect() + } else { + Itertools::intersperse(lines, "\n").collect() + } + })?; + + action_log.update(cx, |log, cx| { + log.buffer_read(buffer, cx); + })?; + + Ok(result) + } else { + // No line ranges specified, so check file size to see if it's too big. + let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?; + + if file_size <= MAX_FILE_SIZE_TO_READ { + let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?; + + action_log.update(cx, |log, cx| { + log.buffer_read(buffer, cx); + })?; + + Ok(result) + } else { + // File is too big, so return its outline and a suggestion to + // read again with a line number range specified. + let outline = file_outline(project, file_path, action_log, None, 0, cx).await?; + + Ok(format!("This file was too big to read all at once. Here is an outline of its symbols:\n\n{outline}\n\nUsing the line numbers in this outline, you can call this tool again while specifying the start and end fields to see the implementations of symbols in the outline.")) + } + } + }) + } + } +} diff --git a/crates/assistant_tools/src/contents_tool/description.md b/crates/assistant_tools/src/contents_tool/description.md new file mode 100644 index 0000000000..b532f7c534 --- /dev/null +++ b/crates/assistant_tools/src/contents_tool/description.md @@ -0,0 +1,9 @@ +Reads the contents of a path on the filesystem. + +If the path is a directory, this lists all files and directories within that path. +If the path is a file, this returns the file's contents. + +When reading a file, if the file is too big and no line range is specified, an outline of the file's code symbols is listed instead, which can be used to request specific line ranges in a subsequent call. + +Similarly, if a directory has too many entries to show at once, a subset of entries will be shown, +and subsequent requests can use starting and ending line numbers to get other subsets. From d4761cea4781995cef179fc1cf359c6ce1f2c4eb Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 15 Apr 2025 02:32:28 -0400 Subject: [PATCH 67/75] debugger: Remember pane layout from previous debugger session (#28692) This PR makes a debugger's pane layout persistent across session's that use the same debug adapter. Release Notes: - N/A --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: Cole Miller --- Cargo.lock | 1 + crates/db/src/kvp.rs | 1 + crates/debugger_ui/Cargo.toml | 1 + crates/debugger_ui/src/debugger_panel.rs | 68 ++-- crates/debugger_ui/src/debugger_ui.rs | 1 + crates/debugger_ui/src/persistence.rs | 259 +++++++++++++ crates/debugger_ui/src/session.rs | 3 + crates/debugger_ui/src/session/running.rs | 343 ++++++++++++------ .../src/session/running/breakpoint_list.rs | 2 +- crates/debugger_ui/src/tests.rs | 1 + .../debugger_ui/src/tests/debugger_panel.rs | 6 + crates/debugger_ui/src/tests/variable_list.rs | 2 + crates/project/src/debugger/session.rs | 12 +- 13 files changed, 550 insertions(+), 150 deletions(-) create mode 100644 crates/debugger_ui/src/persistence.rs diff --git a/Cargo.lock b/Cargo.lock index b95c9dce18..2c9513970f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4177,6 +4177,7 @@ dependencies = [ "collections", "command_palette_hooks", "dap", + "db", "editor", "env_logger 0.11.8", "feature_flags", diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index c9d994d34d..d501368c85 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -1,6 +1,7 @@ use sqlez_macros::sql; use crate::{define_connection, query}; +pub static DEBUGGER_PANEL_PREFIX: &str = "debugger_panel_"; define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = &[sql!( diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index 239c47d07d..ac180f308f 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -28,6 +28,7 @@ client.workspace = true collections.workspace = true command_palette_hooks.workspace = true dap.workspace = true +db.workspace = true editor.workspace = true feature_flags.workspace = true futures.workspace = true diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 378709450e..9cec74d27e 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -1,6 +1,6 @@ use crate::{ ClearAllBreakpoints, Continue, CreateDebuggingSession, Disconnect, Pause, Restart, StepBack, - StepInto, StepOut, StepOver, Stop, ToggleIgnoreBreakpoints, + StepInto, StepOut, StepOver, Stop, ToggleIgnoreBreakpoints, persistence, }; use crate::{new_session_modal::NewSessionModal, session::DebugSession}; use anyhow::{Result, anyhow}; @@ -293,35 +293,49 @@ impl DebugPanel { ); }; - let Some(project) = self.project.upgrade() else { - return log::error!("Debug Panel out lived it's weak reference to Project"); - }; + let adapter_name = session.read(cx).adapter_name(); - if self - .sessions - .iter() - .any(|item| item.read(cx).session_id(cx) == *session_id) - { - // We already have an item for this session. - return; - } - let session_item = DebugSession::running( - project, - self.workspace.clone(), - session, - cx.weak_entity(), - window, - cx, - ); + let session_id = *session_id; + cx.spawn_in(window, async move |this, cx| { + let serialized_layout = + persistence::get_serialized_pane_layout(adapter_name).await; - if let Some(running) = session_item.read(cx).mode().as_running().cloned() { - // We might want to make this an event subscription and only notify when a new thread is selected - // This is used to filter the command menu correctly - cx.observe(&running, |_, _, cx| cx.notify()).detach(); - } + this.update_in(cx, |this, window, cx| { + let Some(project) = this.project.upgrade() else { + return log::error!( + "Debug Panel out lived it's weak reference to Project" + ); + }; - self.sessions.push(session_item.clone()); - self.activate_session(session_item, window, cx); + if this + .sessions + .iter() + .any(|item| item.read(cx).session_id(cx) == session_id) + { + // We already have an item for this session. + return; + } + let session_item = DebugSession::running( + project, + this.workspace.clone(), + session, + cx.weak_entity(), + serialized_layout, + window, + cx, + ); + + if let Some(running) = session_item.read(cx).mode().as_running().cloned() { + // We might want to make this an event subscription and only notify when a new thread is selected + // This is used to filter the command menu correctly + cx.observe(&running, |_, _, cx| cx.notify()).detach(); + } + + this.sessions.push(session_item.clone()); + this.activate_session(session_item, window, cx); + }) + }) + .detach(); } dap_store::DapStoreEvent::RunInTerminal { title, diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index 7a89b275ea..55eea2315e 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -13,6 +13,7 @@ use workspace::{ShutdownDebugAdapters, Workspace}; pub mod attach_modal; pub mod debugger_panel; mod new_session_modal; +mod persistence; pub(crate) mod session; #[cfg(test)] diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs new file mode 100644 index 0000000000..0472675268 --- /dev/null +++ b/crates/debugger_ui/src/persistence.rs @@ -0,0 +1,259 @@ +use collections::HashMap; +use db::kvp::KEY_VALUE_STORE; +use gpui::{Axis, Context, Entity, EntityId, Focusable, Subscription, WeakEntity, Window}; +use project::Project; +use serde::{Deserialize, Serialize}; +use ui::{App, SharedString}; +use util::ResultExt; +use workspace::{Member, Pane, PaneAxis, Workspace}; + +use crate::session::running::{ + self, RunningState, SubView, breakpoint_list::BreakpointList, console::Console, + module_list::ModuleList, stack_frame_list::StackFrameList, variable_list::VariableList, +}; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum DebuggerPaneItem { + Console, + Variables, + BreakpointList, + Frames, + Modules, +} + +impl DebuggerPaneItem { + pub(crate) fn to_shared_string(self) -> SharedString { + match self { + DebuggerPaneItem::Console => SharedString::new_static("Console"), + DebuggerPaneItem::Variables => SharedString::new_static("Variables"), + DebuggerPaneItem::BreakpointList => SharedString::new_static("Breakpoints"), + DebuggerPaneItem::Frames => SharedString::new_static("Frames"), + DebuggerPaneItem::Modules => SharedString::new_static("Modules"), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedAxis(pub Axis); + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum SerializedPaneLayout { + Pane(SerializedPane), + Group { + axis: SerializedAxis, + flexes: Option>, + children: Vec, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedPane { + pub children: Vec, + pub active_item: Option, +} + +pub(crate) async fn serialize_pane_layout( + adapter_name: SharedString, + pane_group: SerializedPaneLayout, +) -> anyhow::Result<()> { + if let Ok(serialized_pane_group) = serde_json::to_string(&pane_group) { + KEY_VALUE_STORE + .write_kvp( + format!("{}-{adapter_name}", db::kvp::DEBUGGER_PANEL_PREFIX), + serialized_pane_group, + ) + .await + } else { + Err(anyhow::anyhow!( + "Failed to serialize pane group with serde_json as a string" + )) + } +} + +pub(crate) fn build_serialized_pane_layout( + pane_group: &Member, + cx: &mut App, +) -> SerializedPaneLayout { + match pane_group { + Member::Axis(PaneAxis { + axis, + members, + flexes, + bounding_boxes: _, + }) => SerializedPaneLayout::Group { + axis: SerializedAxis(*axis), + children: members + .iter() + .map(|member| build_serialized_pane_layout(member, cx)) + .collect::>(), + flexes: Some(flexes.lock().clone()), + }, + Member::Pane(pane_handle) => SerializedPaneLayout::Pane(serialize_pane(pane_handle, cx)), + } +} + +fn serialize_pane(pane: &Entity, cx: &mut App) -> SerializedPane { + let pane = pane.read(cx); + let children = pane + .items() + .filter_map(|item| { + item.act_as::(cx) + .map(|view| view.read(cx).view_kind()) + }) + .collect::>(); + + let active_item = pane + .active_item() + .and_then(|item| item.act_as::(cx)) + .map(|view| view.read(cx).view_kind()); + + SerializedPane { + children, + active_item, + } +} + +pub(crate) async fn get_serialized_pane_layout( + adapter_name: impl AsRef, +) -> Option { + let key = format!( + "{}-{}", + db::kvp::DEBUGGER_PANEL_PREFIX, + adapter_name.as_ref() + ); + + KEY_VALUE_STORE + .read_kvp(&key) + .log_err() + .flatten() + .and_then(|value| serde_json::from_str::(&value).ok()) +} + +pub(crate) fn deserialize_pane_layout( + serialized: SerializedPaneLayout, + workspace: &WeakEntity, + project: &Entity, + stack_frame_list: &Entity, + variable_list: &Entity, + module_list: &Entity, + console: &Entity, + breakpoint_list: &Entity, + subscriptions: &mut HashMap, + window: &mut Window, + cx: &mut Context, +) -> Option { + match serialized { + SerializedPaneLayout::Group { + axis, + flexes, + children, + } => { + let mut members = Vec::new(); + for child in children { + if let Some(new_member) = deserialize_pane_layout( + child, + workspace, + project, + stack_frame_list, + variable_list, + module_list, + console, + breakpoint_list, + subscriptions, + window, + cx, + ) { + members.push(new_member); + } + } + + if members.is_empty() { + return None; + } + + if members.len() == 1 { + return Some(members.remove(0)); + } + + Some(Member::Axis(PaneAxis::load( + axis.0, + members, + flexes.clone(), + ))) + } + SerializedPaneLayout::Pane(serialized_pane) => { + let pane = running::new_debugger_pane(workspace.clone(), project.clone(), window, cx); + subscriptions.insert( + pane.entity_id(), + cx.subscribe_in(&pane, window, RunningState::handle_pane_event), + ); + + let sub_views: Vec<_> = serialized_pane + .children + .iter() + .map(|child| match child { + DebuggerPaneItem::Frames => Box::new(SubView::new( + pane.focus_handle(cx), + stack_frame_list.clone().into(), + DebuggerPaneItem::Frames, + None, + cx, + )), + DebuggerPaneItem::Variables => Box::new(SubView::new( + variable_list.focus_handle(cx), + variable_list.clone().into(), + DebuggerPaneItem::Variables, + None, + cx, + )), + DebuggerPaneItem::BreakpointList => Box::new(SubView::new( + breakpoint_list.focus_handle(cx), + breakpoint_list.clone().into(), + DebuggerPaneItem::BreakpointList, + None, + cx, + )), + DebuggerPaneItem::Modules => Box::new(SubView::new( + pane.focus_handle(cx), + module_list.clone().into(), + DebuggerPaneItem::Modules, + None, + cx, + )), + + DebuggerPaneItem::Console => Box::new(SubView::new( + pane.focus_handle(cx), + console.clone().into(), + DebuggerPaneItem::Console, + Some(Box::new({ + let console = console.clone().downgrade(); + move |cx| { + console + .read_with(cx, |console, cx| console.show_indicator(cx)) + .unwrap_or_default() + } + })), + cx, + )), + }) + .collect(); + + pane.update(cx, |pane, cx| { + let mut active_idx = 0; + for (idx, sub_view) in sub_views.into_iter().enumerate() { + if serialized_pane + .active_item + .is_some_and(|active| active == sub_view.read(cx).view_kind()) + { + active_idx = idx; + } + pane.add_item(sub_view, false, false, None, window, cx); + } + + pane.activate_item(active_idx, false, false, window, cx); + }); + + Some(Member::Pane(pane.clone())) + } + } +} diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs index c69a2259b2..93fbdc1111 100644 --- a/crates/debugger_ui/src/session.rs +++ b/crates/debugger_ui/src/session.rs @@ -16,6 +16,7 @@ use workspace::{ }; use crate::debugger_panel::DebugPanel; +use crate::persistence::SerializedPaneLayout; pub(crate) enum DebugSessionState { Running(Entity), @@ -52,6 +53,7 @@ impl DebugSession { workspace: WeakEntity, session: Entity, _debug_panel: WeakEntity, + serialized_pane_layout: Option, window: &mut Window, cx: &mut App, ) -> Entity { @@ -60,6 +62,7 @@ impl DebugSession { session.clone(), project.clone(), workspace.clone(), + serialized_pane_layout, window, cx, ) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 25386ae005..d3d3d5637e 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -1,11 +1,13 @@ -mod breakpoint_list; -mod console; -mod loaded_source_list; -mod module_list; +pub(crate) mod breakpoint_list; +pub(crate) mod console; +pub(crate) mod loaded_source_list; +pub(crate) mod module_list; pub mod stack_frame_list; pub mod variable_list; -use std::{any::Any, ops::ControlFlow, sync::Arc}; +use std::{any::Any, ops::ControlFlow, sync::Arc, time::Duration}; + +use crate::persistence::{self, DebuggerPaneItem, SerializedPaneLayout}; use super::DebugPanelItemEvent; use breakpoint_list::BreakpointList; @@ -14,7 +16,7 @@ use console::Console; use dap::{Capabilities, Thread, client::SessionId, debugger_settings::DebuggerSettings}; use gpui::{ Action as _, AnyView, AppContext, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - NoAction, Subscription, WeakEntity, + NoAction, Subscription, Task, WeakEntity, }; use loaded_source_list::LoadedSourceList; use module_list::ModuleList; @@ -33,8 +35,8 @@ use ui::{ use util::ResultExt; use variable_list::VariableList; use workspace::{ - ActivePaneDecorator, DraggedTab, Item, Pane, PaneGroup, Workspace, item::TabContentParams, - move_item, pane::Event, + ActivePaneDecorator, DraggedTab, Item, Member, Pane, PaneGroup, Workspace, + item::TabContentParams, move_item, pane::Event, }; pub struct RunningState { @@ -51,6 +53,7 @@ pub struct RunningState { _console: Entity, panes: PaneGroup, pane_close_subscriptions: HashMap, + _schedule_serialize: Option>, } impl Render for RunningState { @@ -84,28 +87,32 @@ impl Render for RunningState { } } -struct SubView { +pub(crate) struct SubView { inner: AnyView, pane_focus_handle: FocusHandle, - tab_name: SharedString, + kind: DebuggerPaneItem, show_indicator: Box bool>, } impl SubView { - fn new( + pub(crate) fn new( pane_focus_handle: FocusHandle, view: AnyView, - tab_name: SharedString, + kind: DebuggerPaneItem, show_indicator: Option bool>>, cx: &mut App, ) -> Entity { cx.new(|_| Self { - tab_name, + kind, inner: view, pane_focus_handle, show_indicator: show_indicator.unwrap_or(Box::new(|_| false)), }) } + + pub(crate) fn view_kind(&self) -> DebuggerPaneItem { + self.kind + } } impl Focusable for SubView { fn focus_handle(&self, _: &App) -> FocusHandle { @@ -116,13 +123,19 @@ impl EventEmitter<()> for SubView {} impl Item for SubView { type Event = (); + /// This is used to serialize debugger pane layouts + /// A SharedString gets converted to a enum and back during serialization/deserialization. + fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option { + Some(self.kind.to_shared_string()) + } + fn tab_content( &self, params: workspace::item::TabContentParams, _: &Window, cx: &App, ) -> AnyElement { - let label = Label::new(self.tab_name.clone()) + let label = Label::new(self.kind.to_shared_string()) .size(ui::LabelSize::Small) .color(params.text_color()) .line_height_style(ui::LineHeightStyle::UiLabel); @@ -146,7 +159,7 @@ impl Render for SubView { } } -fn new_debugger_pane( +pub(crate) fn new_debugger_pane( workspace: WeakEntity, project: Entity, window: &mut Window, @@ -185,7 +198,7 @@ fn new_debugger_pane( new_debugger_pane(workspace.clone(), project.clone(), window, cx); let _previous_subscription = running.pane_close_subscriptions.insert( new_pane.entity_id(), - cx.subscribe(&new_pane, RunningState::handle_pane_event), + cx.subscribe_in(&new_pane, window, RunningState::handle_pane_event), ); debug_assert!(_previous_subscription.is_none()); running @@ -354,6 +367,7 @@ impl RunningState { session: Entity, project: Entity, workspace: WeakEntity, + serialized_pane_layout: Option, window: &mut Window, cx: &mut Context, ) -> Self { @@ -382,6 +396,8 @@ impl RunningState { ) }); + let breakpoints = BreakpointList::new(session.clone(), workspace.clone(), &project, cx); + let _subscriptions = vec![ cx.observe(&module_list, |_, _, cx| cx.notify()), cx.subscribe_in(&session, window, |this, _, event, window, cx| { @@ -407,112 +423,40 @@ impl RunningState { }), ]; - let leftmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); - leftmost_pane.update(cx, |this, cx| { - this.add_item( - Box::new(SubView::new( - this.focus_handle(cx), - stack_frame_list.clone().into(), - SharedString::new_static("Frames"), - None, - cx, - )), - true, - false, - None, + let mut pane_close_subscriptions = HashMap::default(); + let panes = if let Some(root) = serialized_pane_layout.and_then(|serialized_layout| { + persistence::deserialize_pane_layout( + serialized_layout, + &workspace, + &project, + &stack_frame_list, + &variable_list, + &module_list, + &console, + &breakpoints, + &mut pane_close_subscriptions, + window, + cx, + ) + }) { + workspace::PaneGroup::with_root(root) + } else { + pane_close_subscriptions.clear(); + let root = Self::default_pane_layout( + project, + &workspace, + &stack_frame_list, + &variable_list, + &module_list, + &console, + breakpoints, + &mut pane_close_subscriptions, window, cx, ); - let breakpoints = BreakpointList::new(session.clone(), workspace.clone(), &project, cx); - this.add_item( - Box::new(SubView::new( - breakpoints.focus_handle(cx), - breakpoints.into(), - SharedString::new_static("Breakpoints"), - None, - cx, - )), - true, - false, - None, - window, - cx, - ); - this.activate_item(0, false, false, window, cx); - }); - let center_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); - center_pane.update(cx, |this, cx| { - this.add_item( - Box::new(SubView::new( - variable_list.focus_handle(cx), - variable_list.clone().into(), - SharedString::new_static("Variables"), - None, - cx, - )), - true, - false, - None, - window, - cx, - ); - this.add_item( - Box::new(SubView::new( - this.focus_handle(cx), - module_list.clone().into(), - SharedString::new_static("Modules"), - None, - cx, - )), - false, - false, - None, - window, - cx, - ); - this.activate_item(0, false, false, window, cx); - }); - let rightmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); - rightmost_pane.update(cx, |this, cx| { - let weak_console = console.downgrade(); - this.add_item( - Box::new(SubView::new( - this.focus_handle(cx), - console.clone().into(), - SharedString::new_static("Console"), - Some(Box::new(move |cx| { - weak_console - .read_with(cx, |console, cx| console.show_indicator(cx)) - .unwrap_or_default() - })), - cx, - )), - true, - false, - None, - window, - cx, - ); - }); - let pane_close_subscriptions = HashMap::from_iter( - [&leftmost_pane, ¢er_pane, &rightmost_pane] - .into_iter() - .map(|entity| { - ( - entity.entity_id(), - cx.subscribe(entity, Self::handle_pane_event), - ) - }), - ); - let group_root = workspace::PaneAxis::new( - gpui::Axis::Horizontal, - [leftmost_pane, center_pane, rightmost_pane] - .into_iter() - .map(workspace::Member::Pane) - .collect(), - ); - let panes = PaneGroup::with_root(workspace::Member::Axis(group_root)); + workspace::PaneGroup::with_root(root) + }; Self { session, @@ -528,21 +472,57 @@ impl RunningState { _module_list: module_list, _console: console, pane_close_subscriptions, + _schedule_serialize: None, } } - fn handle_pane_event( + fn serialize_layout(&mut self, window: &mut Window, cx: &mut Context) { + if self._schedule_serialize.is_none() { + self._schedule_serialize = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + + let Some((adapter_name, pane_group)) = this + .update(cx, |this, cx| { + let adapter_name = this.session.read(cx).adapter_name(); + ( + adapter_name, + persistence::build_serialized_pane_layout(&this.panes.root, cx), + ) + }) + .ok() + else { + return; + }; + + persistence::serialize_pane_layout(adapter_name, pane_group) + .await + .log_err(); + + this.update(cx, |this, _| { + this._schedule_serialize.take(); + }) + .ok(); + })); + } + } + + pub(crate) fn handle_pane_event( this: &mut RunningState, - source_pane: Entity, + source_pane: &Entity, event: &Event, + window: &mut Window, cx: &mut Context, ) { + this.serialize_layout(window, cx); if let Event::Remove { .. } = event { let _did_find_pane = this.panes.remove(&source_pane).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } } + pub(crate) fn go_to_selected_stack_frame(&self, window: &Window, cx: &mut Context) { if self.thread_id.is_some() { self.stack_frame_list @@ -586,7 +566,7 @@ impl RunningState { .find_map(|pane| { pane.read(cx) .items_of_type::() - .position(|view| view.read(cx).tab_name == *"Modules") + .position(|view| view.read(cx).view_kind().to_shared_string() == *"Modules") .map(|view| (view, pane)) }) .unwrap(); @@ -802,6 +782,127 @@ impl RunningState { }), ) } + + fn default_pane_layout( + project: Entity, + workspace: &WeakEntity, + stack_frame_list: &Entity, + variable_list: &Entity, + module_list: &Entity, + console: &Entity, + breakpoints: Entity, + subscriptions: &mut HashMap, + window: &mut Window, + cx: &mut Context<'_, RunningState>, + ) -> Member { + let leftmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); + leftmost_pane.update(cx, |this, cx| { + this.add_item( + Box::new(SubView::new( + this.focus_handle(cx), + stack_frame_list.clone().into(), + DebuggerPaneItem::Frames, + None, + cx, + )), + true, + false, + None, + window, + cx, + ); + this.add_item( + Box::new(SubView::new( + breakpoints.focus_handle(cx), + breakpoints.into(), + DebuggerPaneItem::BreakpointList, + None, + cx, + )), + true, + false, + None, + window, + cx, + ); + this.activate_item(0, false, false, window, cx); + }); + let center_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); + center_pane.update(cx, |this, cx| { + this.add_item( + Box::new(SubView::new( + variable_list.focus_handle(cx), + variable_list.clone().into(), + DebuggerPaneItem::Variables, + None, + cx, + )), + true, + false, + None, + window, + cx, + ); + this.add_item( + Box::new(SubView::new( + this.focus_handle(cx), + module_list.clone().into(), + DebuggerPaneItem::Modules, + None, + cx, + )), + false, + false, + None, + window, + cx, + ); + this.activate_item(0, false, false, window, cx); + }); + let rightmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); + rightmost_pane.update(cx, |this, cx| { + let weak_console = console.downgrade(); + this.add_item( + Box::new(SubView::new( + this.focus_handle(cx), + console.clone().into(), + DebuggerPaneItem::Console, + Some(Box::new(move |cx| { + weak_console + .read_with(cx, |console, cx| console.show_indicator(cx)) + .unwrap_or_default() + })), + cx, + )), + true, + false, + None, + window, + cx, + ); + }); + + subscriptions.extend( + [&leftmost_pane, ¢er_pane, &rightmost_pane] + .into_iter() + .map(|entity| { + ( + entity.entity_id(), + cx.subscribe_in(entity, window, Self::handle_pane_event), + ) + }), + ); + + let group_root = workspace::PaneAxis::new( + gpui::Axis::Horizontal, + [leftmost_pane, center_pane, rightmost_pane] + .into_iter() + .map(workspace::Member::Pane) + .collect(), + ); + + Member::Axis(group_root) + } } impl EventEmitter for RunningState {} diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index ff7ead4123..d9972284fe 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -27,7 +27,7 @@ use ui::{ use util::{ResultExt, maybe}; use workspace::Workspace; -pub(super) struct BreakpointList { +pub(crate) struct BreakpointList { workspace: WeakEntity, breakpoint_store: Entity, worktree_store: Entity, diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index 0db43bb379..6fbe84a9e5 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -68,6 +68,7 @@ pub async fn init_test_workspace( workspace_handle } +#[track_caller] pub fn active_debug_session_panel( workspace: WindowHandle, cx: &mut TestAppContext, diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 0dd83f7143..a19d852a85 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -81,6 +81,8 @@ async fn test_basic_show_debug_panel(executor: BackgroundExecutor, cx: &mut Test }) .await; + cx.run_until_parked(); + // assert we have a debug panel item before the session has stopped workspace .update(cx, |workspace, _window, cx| { @@ -229,6 +231,8 @@ async fn test_we_can_only_have_one_panel_per_debug_session( }) .await; + cx.run_until_parked(); + // assert we have a debug panel item before the session has stopped workspace .update(cx, |workspace, _window, cx| { @@ -1052,6 +1056,8 @@ async fn test_debug_panel_item_thread_status_reset_on_failure( })) .await; + cx.run_until_parked(); + let running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, _, _| { item.mode() .as_running() diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 8c352731fb..c4001cc5e2 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -1538,6 +1538,8 @@ async fn test_variable_list_only_sends_requests_when_rendering( }) .await; + cx.run_until_parked(); + let running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, _, _| { let state = item .mode() diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index ff861771ef..401494ddcc 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -30,7 +30,8 @@ use dap::{ use futures::channel::oneshot; use futures::{FutureExt, future::Shared}; use gpui::{ - App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Task, WeakEntity, + App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString, + Task, WeakEntity, }; use rpc::AnyProtoClient; use serde_json::{Value, json}; @@ -125,6 +126,7 @@ type UpstreamProjectId = u64; struct RemoteConnection { _client: AnyProtoClient, _upstream_project_id: UpstreamProjectId, + _adapter_name: SharedString, } impl RemoteConnection { @@ -996,6 +998,7 @@ impl Session { ) -> Self { Self { mode: Mode::Remote(RemoteConnection { + _adapter_name: SharedString::new(""), // todo(debugger) we need to pipe in the right values to deserialize the debugger pane layout _client: client, _upstream_project_id: upstream_project_id, }), @@ -1044,6 +1047,13 @@ impl Session { &self.capabilities } + pub fn adapter_name(&self) -> SharedString { + match &self.mode { + Mode::Local(local_mode) => local_mode.adapter.name().into(), + Mode::Remote(remote_mode) => remote_mode._adapter_name.clone(), + } + } + pub fn configuration(&self) -> Option { if let Mode::Local(local_mode) = &self.mode { Some(local_mode.config.clone()) From cfc848d24b34a5914d1489ff39c840ee96f14f39 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 15 Apr 2025 13:05:10 +0530 Subject: [PATCH 68/75] git_ui: Fix commit modal dismiss on commit menu click (#28744) Release Notes: - N/A --- crates/git_ui/src/commit_modal.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index c90e0deade..d7a11e53ba 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -62,6 +62,7 @@ pub struct CommitModal { restore_dock: RestoreDock, properties: ModalContainerProperties, branch_list_handle: PopoverMenuHandle, + commit_menu_handle: PopoverMenuHandle, } impl Focusable for CommitModal { @@ -171,7 +172,9 @@ impl CommitModal { let focus_handle = commit_editor.focus_handle(cx); cx.on_focus_out(&focus_handle, window, |this, _, window, cx| { - if !this.branch_list_handle.is_focused(window, cx) { + if !this.branch_list_handle.is_focused(window, cx) + && !this.commit_menu_handle.is_focused(window, cx) + { cx.emit(DismissEvent); } }) @@ -185,6 +188,7 @@ impl CommitModal { restore_dock, properties, branch_list_handle: PopoverMenuHandle::default(), + commit_menu_handle: PopoverMenuHandle::default(), } } @@ -246,6 +250,7 @@ impl CommitModal { .action("Amend...", Amend.boxed_clone()) })) }) + .with_handle(self.commit_menu_handle.clone()) .anchor(Corner::TopRight) } From e1c42315dc0747081bad3ed3e970a70214986cb4 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 15 Apr 2025 01:57:54 -0600 Subject: [PATCH 69/75] gemini: Fix "invalid argument" error when request contains no tools (#28747) When we do not have any tools, we want to set the `tools` field to `None` Release Notes: - Fixed an issue where Gemini requests would sometimes return a Bad Request ("Invalid argument...") --- crates/language_models/src/provider/google.rs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 4da5f255d2..36a01a30c2 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -417,17 +417,19 @@ pub fn into_google( top_k: None, }), safety_settings: None, - tools: Some(vec![google_ai::Tool { - function_declarations: request - .tools - .into_iter() - .map(|tool| FunctionDeclaration { - name: tool.name, - description: tool.description, - parameters: tool.input_schema, - }) - .collect(), - }]), + tools: (request.tools.len() > 0).then(|| { + vec![google_ai::Tool { + function_declarations: request + .tools + .into_iter() + .map(|tool| FunctionDeclaration { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }) + .collect(), + }] + }), tool_config: None, } } From 616d17f5170c5d2e20b1db6faac899677afd8ed2 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 15 Apr 2025 13:30:02 +0530 Subject: [PATCH 70/75] git_ui: Force commit modal mode from command palette (#28745) Depending on `git::commit` or `git::amend` action triggered, commit modal opens up in appropriate mode, handling edge cases like if you are already in amend mode, etc. Release Notes: - N/A --- crates/git_ui/src/commit_modal.rs | 87 ++++++++++++------------------- crates/git_ui/src/git_panel.rs | 46 +++++++++------- 2 files changed, 59 insertions(+), 74 deletions(-) diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index d7a11e53ba..dd897eb46a 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -2,7 +2,6 @@ use crate::branch_picker::{self, BranchList}; use crate::git_panel::{GitPanel, commit_message_editor}; use git::repository::CommitOptions; use git::{Amend, Commit, GenerateCommitMessage}; -use language::Buffer; use panel::{panel_button, panel_editor_style, panel_filled_button}; use ui::{ ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*, @@ -100,22 +99,47 @@ struct RestoreDock { active_index: Option, } +pub enum ForceMode { + Amend, + Commit, +} + impl CommitModal { pub fn register(workspace: &mut Workspace) { workspace.register_action(|workspace, _: &Commit, window, cx| { - CommitModal::toggle(workspace, window, cx); + CommitModal::toggle(workspace, Some(ForceMode::Commit), window, cx); }); workspace.register_action(|workspace, _: &Amend, window, cx| { - CommitModal::toggle(workspace, window, cx); + CommitModal::toggle(workspace, Some(ForceMode::Amend), window, cx); }); } - pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + pub fn toggle( + workspace: &mut Workspace, + force_mode: Option, + window: &mut Window, + cx: &mut Context, + ) { let Some(git_panel) = workspace.panel::(cx) else { return; }; git_panel.update(cx, |git_panel, cx| { + if let Some(force_mode) = force_mode { + match force_mode { + ForceMode::Amend => { + if !git_panel.amend_pending() { + git_panel.set_amend_pending(true, cx); + git_panel.load_last_commit_message_if_empty(cx); + } + } + ForceMode::Commit => { + if git_panel.amend_pending() { + git_panel.set_amend_pending(false, cx); + } + } + } + } git_panel.set_modal_open(true, cx); }); @@ -461,23 +485,12 @@ impl CommitModal { fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { if self.git_panel.read(cx).amend_pending() { self.git_panel - .update(cx, |git_panel, _| git_panel.set_amend_pending(false)); - cx.notify(); + .update(cx, |git_panel, cx| git_panel.set_amend_pending(false, cx)); } else { cx.emit(DismissEvent); } } - pub fn commit_message_buffer(&self, cx: &App) -> Entity { - self.commit_editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .clone() - } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { if self.git_panel.read(cx).amend_pending() { return; @@ -490,54 +503,20 @@ impl CommitModal { } fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - let Some(active_repository) = self.git_panel.read(cx).active_repository.as_ref() else { - return; - }; - let Some(branch) = active_repository.read(cx).branch.as_ref() else { - return; - }; - let Some(recent_sha) = branch - .most_recent_commit - .as_ref() - .map(|commit| commit.sha.to_string()) - else { - return; - }; if self .commit_editor .focus_handle(cx) .contains_focused(window, cx) { if !self.git_panel.read(cx).amend_pending() { - self.git_panel.update(cx, |git_panel, _| { - git_panel.set_amend_pending(true); + self.git_panel.update(cx, |git_panel, cx| { + git_panel.set_amend_pending(true, cx); + git_panel.load_last_commit_message_if_empty(cx); }); - cx.notify(); - if self.commit_editor.read(cx).is_empty(cx) { - let detail_task = self.git_panel.update(cx, |git_panel, cx| { - git_panel.load_commit_details(recent_sha, cx) - }); - cx.spawn(async move |this, cx| { - if let Ok(message) = detail_task.await.map(|detail| detail.message) { - this.update(cx, |this, cx| { - this.commit_message_buffer(cx).update(cx, |buffer, cx| { - let insert_position = buffer.anchor_before(buffer.len()); - buffer.edit( - [(insert_position..insert_position, message)], - None, - cx, - ); - }); - }) - .log_err(); - } - }) - .detach(); - } } else { telemetry::event!("Git Amended", source = "Git Panel"); self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(false); + git_panel.set_amend_pending(false, cx); git_panel.commit_changes(CommitOptions { amend: true }, window, cx); }); cx.emit(DismissEvent); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 6a5c0fb372..e8d27cd442 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -167,7 +167,7 @@ pub fn register(workspace: &mut Workspace) { workspace.toggle_panel_focus::(window, cx); }); workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| { - CommitModal::toggle(workspace, window, cx) + CommitModal::toggle(workspace, None, window, cx) }); } @@ -1434,7 +1434,10 @@ impl GitPanel { } } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context) { + if !self.commit_editor.read(cx).is_empty(cx) { + return; + } let Some(active_repository) = self.active_repository.as_ref() else { return; }; @@ -1448,6 +1451,23 @@ impl GitPanel { else { return; }; + let detail_task = self.load_commit_details(recent_sha, cx); + cx.spawn(async move |this, cx| { + if let Ok(message) = detail_task.await.map(|detail| detail.message) { + this.update(cx, |this, cx| { + this.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, message)], None, cx); + }); + }) + .log_err(); + } + }) + .detach(); + } + + fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { if self .commit_editor .focus_handle(cx) @@ -1456,22 +1476,7 @@ impl GitPanel { if !self.amend_pending { self.amend_pending = true; cx.notify(); - if self.commit_editor.read(cx).is_empty(cx) { - let detail_task = self.load_commit_details(recent_sha, cx); - cx.spawn(async move |this, cx| { - if let Ok(message) = detail_task.await.map(|detail| detail.message) { - this.update(cx, |this, cx| { - this.commit_message_buffer(cx).update(cx, |buffer, cx| { - let start = buffer.anchor_before(0); - let end = buffer.anchor_after(buffer.len()); - buffer.edit([(start..end, message)], None, cx); - }); - }) - .log_err(); - } - }) - .detach(); - } + self.load_last_commit_message_if_empty(cx); } else { telemetry::event!("Git Amended", source = "Git Panel"); self.amend_pending = false; @@ -2859,7 +2864,7 @@ impl GitPanel { window.defer(cx, move |window, cx| { workspace .update(cx, |workspace, cx| { - CommitModal::toggle(workspace, window, cx) + CommitModal::toggle(workspace, None, window, cx) }) .ok(); }) @@ -3997,8 +4002,9 @@ impl GitPanel { self.amend_pending } - pub fn set_amend_pending(&mut self, value: bool) { + pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { self.amend_pending = value; + cx.notify(); } } From d4a985a6e324331bda0b0af496ec0dbb66bdf166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Mockers?= Date: Tue, 15 Apr 2025 13:12:37 +0200 Subject: [PATCH 71/75] Case Insensitive Unicode Text Search: Fallback To Regex (#28752) Closes #9980 Release Notes: - Fixed: case insensitive text search with unicode characters --- crates/project/src/project_tests.rs | 81 +++++++++++++++++++++++++++++ crates/project/src/search.rs | 15 ++++++ 2 files changed, 96 insertions(+) diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index fd1106b11d..d981696a08 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -5425,6 +5425,87 @@ async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_search_with_unicode(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "// ПРИВЕТ? привет!", + "two.rs": "// ПРИВЕТ.", + "three.rs": "// привет", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + + let unicode_case_sensitive_query = SearchQuery::text( + "привет", + false, + true, + false, + Default::default(), + Default::default(), + None, + ); + assert_matches!(unicode_case_sensitive_query, Ok(SearchQuery::Text { .. })); + assert_eq!( + search(&project, unicode_case_sensitive_query.unwrap(), cx) + .await + .unwrap(), + HashMap::from_iter([ + (separator!("dir/one.rs").to_string(), vec![17..29]), + (separator!("dir/three.rs").to_string(), vec![3..15]), + ]) + ); + + let unicode_case_insensitive_query = SearchQuery::text( + "привет", + false, + false, + false, + Default::default(), + Default::default(), + None, + ); + assert_matches!( + unicode_case_insensitive_query, + Ok(SearchQuery::Regex { .. }) + ); + assert_eq!( + search(&project, unicode_case_insensitive_query.unwrap(), cx) + .await + .unwrap(), + HashMap::from_iter([ + (separator!("dir/one.rs").to_string(), vec![3..15, 17..29]), + (separator!("dir/two.rs").to_string(), vec![3..15]), + (separator!("dir/three.rs").to_string(), vec![3..15]), + ]) + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + "привет.", + false, + false, + false, + Default::default(), + Default::default(), + None, + ) + .unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([(separator!("dir/two.rs").to_string(), vec![3..16]),]) + ); +} + #[gpui::test] async fn test_create_entry(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 06745c82f4..d23bb9a9b8 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -93,6 +93,21 @@ impl SearchQuery { buffers: Option>>, ) -> Result { let query = query.to_string(); + if !case_sensitive && !query.is_ascii() { + // AhoCorasickBuilder doesn't support case-insensitive search with unicode characters + // Fallback to regex search as recommended by + // https://docs.rs/aho-corasick/1.1/aho_corasick/struct.AhoCorasickBuilder.html#method.ascii_case_insensitive + return Self::regex( + regex::escape(&query), + whole_word, + case_sensitive, + include_ignored, + false, + files_to_include, + files_to_exclude, + buffers, + ); + } let search = AhoCorasickBuilder::new() .ascii_case_insensitive(!case_sensitive) .build([&query])?; From 98d001bad57c89728767680f09a53213654a5279 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 15 Apr 2025 14:13:19 +0200 Subject: [PATCH 72/75] debugger: Always show process list in attach (#28685) Closes #ISSUE Release Notes: - N/A --- Cargo.lock | 2 - crates/dap/Cargo.toml | 1 - crates/dap/src/adapters.rs | 15 +-- crates/dap/src/registry.rs | 2 +- crates/dap_adapters/Cargo.toml | 1 - crates/dap_adapters/src/dap_adapters.rs | 2 +- crates/dap_adapters/src/javascript.rs | 17 +--- crates/debugger_ui/src/attach_modal.rs | 24 ++--- crates/debugger_ui/src/new_session_modal.rs | 101 +++++++++---------- crates/debugger_ui/src/tests/attach_modal.rs | 25 ++++- 10 files changed, 78 insertions(+), 112 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c9513970f..9ee0f5c3ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4001,7 +4001,6 @@ dependencies = [ "node_runtime", "parking_lot", "paths", - "regex", "schemars", "serde", "serde_json", @@ -4033,7 +4032,6 @@ dependencies = [ "gpui", "language", "paths", - "regex", "serde", "serde_json", "task", diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml index 5a44d6b946..0fdd19c93e 100644 --- a/crates/dap/Cargo.toml +++ b/crates/dap/Cargo.toml @@ -39,7 +39,6 @@ log.workspace = true node_runtime.workspace = true parking_lot.workspace = true paths.workspace = true -regex.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs index 175fdd8c2d..5f1083d0d6 100644 --- a/crates/dap/src/adapters.rs +++ b/crates/dap/src/adapters.rs @@ -20,7 +20,7 @@ use std::{ net::Ipv4Addr, ops::Deref, path::PathBuf, - sync::{Arc, LazyLock}, + sync::Arc, }; use task::{DebugAdapterConfig, DebugTaskDefinition}; use util::ResultExt; @@ -291,14 +291,7 @@ pub trait DebugAdapter: 'static + Send + Sync { /// Should return base configuration to make the debug adapter work fn request_args(&self, config: &DebugTaskDefinition) -> Value; - - fn attach_processes_filter(&self) -> regex::Regex { - EMPTY_REGEX.clone() - } } - -static EMPTY_REGEX: LazyLock = - LazyLock::new(|| regex::Regex::new("").expect("Regex compilation to succeed")); #[cfg(any(test, feature = "test-support"))] pub struct FakeAdapter {} @@ -375,10 +368,4 @@ impl DebugAdapter for FakeAdapter { }, }) } - - fn attach_processes_filter(&self) -> regex::Regex { - static REGEX: LazyLock = - LazyLock::new(|| regex::Regex::new("^fake-binary").unwrap()); - REGEX.clone() - } } diff --git a/crates/dap/src/registry.rs b/crates/dap/src/registry.rs index 2a3f0869fb..b6c8efea40 100644 --- a/crates/dap/src/registry.rs +++ b/crates/dap/src/registry.rs @@ -8,7 +8,7 @@ struct DapRegistryState { adapters: BTreeMap>, } -#[derive(Default)] +#[derive(Clone, Default)] /// Stores available debug adapters. pub struct DapRegistry(Arc>); diff --git a/crates/dap_adapters/Cargo.toml b/crates/dap_adapters/Cargo.toml index 40ca634a26..0a11724aa2 100644 --- a/crates/dap_adapters/Cargo.toml +++ b/crates/dap_adapters/Cargo.toml @@ -27,7 +27,6 @@ dap.workspace = true gpui.workspace = true language.workspace = true paths.workspace = true -regex.workspace = true serde.workspace = true serde_json.workspace = true task.workspace = true diff --git a/crates/dap_adapters/src/dap_adapters.rs b/crates/dap_adapters/src/dap_adapters.rs index 320b5336fc..f6c6f7844c 100644 --- a/crates/dap_adapters/src/dap_adapters.rs +++ b/crates/dap_adapters/src/dap_adapters.rs @@ -31,7 +31,7 @@ pub fn init(registry: Arc) { registry.add_adapter(Arc::from(CodeLldbDebugAdapter::default())); registry.add_adapter(Arc::from(PythonDebugAdapter)); registry.add_adapter(Arc::from(PhpDebugAdapter)); - registry.add_adapter(Arc::from(JsDebugAdapter::default())); + registry.add_adapter(Arc::from(JsDebugAdapter)); registry.add_adapter(Arc::from(LldbDebugAdapter)); registry.add_adapter(Arc::from(GoDebugAdapter)); registry.add_adapter(Arc::from(GdbDebugAdapter)); diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index 5022f0ac76..11dee971b1 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -1,24 +1,13 @@ use adapters::latest_github_release; use gpui::AsyncApp; -use regex::Regex; use std::path::PathBuf; use task::{DebugRequestType, DebugTaskDefinition}; use crate::*; #[derive(Debug)] -pub(crate) struct JsDebugAdapter { - attach_processes: Regex, -} +pub(crate) struct JsDebugAdapter; -impl Default for JsDebugAdapter { - fn default() -> Self { - Self { - attach_processes: Regex::new(r"(?i)^(?:node|bun|iojs)(?:$|\b)") - .expect("Regex compilation to succeed"), - } - } -} impl JsDebugAdapter { const ADAPTER_NAME: &'static str = "JavaScript"; const ADAPTER_NPM_NAME: &'static str = "vscode-js-debug"; @@ -149,8 +138,4 @@ impl DebugAdapter for JsDebugAdapter { } args } - - fn attach_processes_filter(&self) -> Regex { - self.attach_processes.clone() - } } diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 0ba8efc5e3..870d09aa3f 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -4,7 +4,6 @@ use gpui::Subscription; use gpui::{DismissEvent, Entity, EventEmitter, Focusable, Render}; use picker::{Picker, PickerDelegate}; -use std::cell::LazyCell; use std::sync::Arc; use sysinfo::System; use ui::{Context, Tooltip, prelude::*}; @@ -24,7 +23,7 @@ pub(crate) struct AttachModalDelegate { matches: Vec, placeholder_text: Arc, project: Entity, - debug_config: task::DebugTaskDefinition, + pub(crate) debug_config: task::DebugTaskDefinition, candidates: Arc<[Candidate]>, } @@ -58,7 +57,7 @@ impl AttachModal { window: &mut Window, cx: &mut Context, ) -> Self { - let mut processes: Vec<_> = System::new_all() + let mut processes: Box<[_]> = System::new_all() .processes() .values() .map(|process| { @@ -75,30 +74,18 @@ impl AttachModal { }) .collect(); processes.sort_by_key(|k| k.name.clone()); + let processes = processes.into_iter().collect(); Self::with_processes(project, debug_config, processes, modal, window, cx) } pub(super) fn with_processes( project: Entity, debug_config: task::DebugTaskDefinition, - processes: Vec, + processes: Arc<[Candidate]>, modal: bool, window: &mut Window, cx: &mut Context, ) -> Self { - let adapter = project - .read(cx) - .debug_adapters() - .adapter(&debug_config.adapter); - let filter = LazyCell::new(|| adapter.map(|adapter| adapter.attach_processes_filter())); - let processes = processes - .into_iter() - .filter(|process| { - filter - .as_ref() - .map_or(false, |filter| filter.is_match(&process.name)) - }) - .collect(); let picker = cx.new(|cx| { Picker::uniform_list( AttachModalDelegate::new(project, debug_config, processes), @@ -117,9 +104,10 @@ impl AttachModal { } impl Render for AttachModal { - fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl ui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { v_flex() .key_context("AttachModal") + .track_focus(&self.focus_handle(cx)) .w(rems(34.)) .child(self.picker.clone()) } diff --git a/crates/debugger_ui/src/new_session_modal.rs b/crates/debugger_ui/src/new_session_modal.rs index b97af68fd6..c6f10bae25 100644 --- a/crates/debugger_ui/src/new_session_modal.rs +++ b/crates/debugger_ui/src/new_session_modal.rs @@ -11,6 +11,7 @@ use gpui::{ App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, TextStyle, WeakEntity, }; +use project::Project; use settings::Settings; use task::{DebugTaskDefinition, LaunchConfig}; use theme::ThemeSettings; @@ -59,7 +60,7 @@ impl NewSessionModal { debug_panel: WeakEntity, workspace: WeakEntity, window: &mut Window, - cx: &mut App, + cx: &mut Context, ) -> Self { let debugger = past_debug_definition .as_ref() @@ -171,25 +172,13 @@ impl NewSessionModal { attach.update(cx, |this, cx| { if selected_debugger != this.debug_definition.adapter { this.debug_definition.adapter = selected_debugger.into(); - if let Some(project) = this - .workspace - .read_with(cx, |workspace, _| workspace.project().clone()) - .ok() - { - this.attach_picker = Some(cx.new(|cx| { - let modal = AttachModal::new( - project, - this.debug_definition.clone(), - false, - window, - cx, - ); - window.focus(&modal.focus_handle(cx)); - - modal - })); - } + this.attach_picker.update(cx, |this, cx| { + this.picker.update(cx, |this, cx| { + this.delegate.debug_config.adapter = selected_debugger.into(); + this.focus(window, cx); + }) + }); } cx.notify(); @@ -256,7 +245,6 @@ impl NewSessionModal { ContextMenu::build(window, cx, move |mut menu, _, cx| { let setter_for_name = |task: DebugTaskDefinition| { let weak = weak.clone(); - let workspace = workspace.clone(); move |window: &mut Window, cx: &mut App| { weak.update(cx, |this, cx| { this.last_selected_profile_name = Some(SharedString::from(&task.label)); @@ -271,12 +259,19 @@ impl NewSessionModal { ); } DebugRequestType::Attach(_) => { + let Ok(project) = this + .workspace + .read_with(cx, |this, _| this.project().clone()) + else { + return; + }; this.mode = NewSessionMode::attach( this.debugger.clone(), - workspace.clone(), + project, window, cx, ); + this.mode.focus_handle(cx).focus(window); if let Some((debugger, attach)) = this.debugger.as_ref().zip(this.mode.as_attach()) { @@ -365,18 +360,16 @@ impl LaunchMode { #[derive(Clone)] struct AttachMode { - workspace: WeakEntity, debug_definition: DebugTaskDefinition, - attach_picker: Option>, - focus_handle: FocusHandle, + attach_picker: Entity, } impl AttachMode { fn new( debugger: Option, - workspace: WeakEntity, + project: Entity, window: &mut Window, - cx: &mut App, + cx: &mut Context, ) -> Entity { let debug_definition = DebugTaskDefinition { label: "Attach New Session Setup".into(), @@ -387,27 +380,15 @@ impl AttachMode { initialize_args: None, stop_on_entry: Some(false), }; + let attach_picker = cx.new(|cx| { + let modal = AttachModal::new(project, debug_definition.clone(), false, window, cx); + window.focus(&modal.focus_handle(cx)); - let attach_picker = if let Some(project) = debugger.and( - workspace - .read_with(cx, |workspace, _| workspace.project().clone()) - .ok(), - ) { - Some(cx.new(|cx| { - let modal = AttachModal::new(project, debug_definition.clone(), false, window, cx); - window.focus(&modal.focus_handle(cx)); - - modal - })) - } else { - None - }; - - cx.new(|cx| Self { - workspace, + modal + }); + cx.new(|_| Self { debug_definition, attach_picker, - focus_handle: cx.focus_handle(), }) } fn debug_task(&self) -> task::AttachConfig { @@ -444,7 +425,7 @@ impl Focusable for NewSessionMode { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self { NewSessionMode::Launch(entity) => entity.read(cx).program.focus_handle(cx), - NewSessionMode::Attach(entity) => entity.read(cx).focus_handle.clone(), + NewSessionMode::Attach(entity) => entity.read(cx).attach_picker.focus_handle(cx), } } } @@ -476,8 +457,11 @@ impl RenderOnce for LaunchMode { } impl RenderOnce for AttachMode { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - v_flex().w_full().children(self.attach_picker.clone()) + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + v_flex() + .w_full() + .track_focus(&self.attach_picker.focus_handle(cx)) + .child(self.attach_picker.clone()) } } @@ -497,13 +481,17 @@ impl RenderOnce for NewSessionMode { impl NewSessionMode { fn attach( debugger: Option, - workspace: WeakEntity, + project: Entity, window: &mut Window, - cx: &mut App, + cx: &mut Context, ) -> Self { - Self::Attach(AttachMode::new(debugger, workspace, window, cx)) + Self::Attach(AttachMode::new(debugger, project, window, cx)) } - fn launch(past_launch_config: Option, window: &mut Window, cx: &mut App) -> Self { + fn launch( + past_launch_config: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { Self::Launch(LaunchMode::new(past_launch_config, window, cx)) } } @@ -592,18 +580,25 @@ impl Render for NewSessionModal { .toggle_state(matches!(self.mode, NewSessionMode::Attach(_))) .style(ui::ButtonStyle::Subtle) .on_click(cx.listener(|this, _, window, cx| { + let Ok(project) = this + .workspace + .read_with(cx, |this, _| this.project().clone()) + else { + return; + }; this.mode = NewSessionMode::attach( this.debugger.clone(), - this.workspace.clone(), + project, window, cx, ); + this.mode.focus_handle(cx).focus(window); if let Some((debugger, attach)) = this.debugger.as_ref().zip(this.mode.as_attach()) { Self::update_attach_picker(&attach, &debugger, window, cx); } - this.mode.focus_handle(cx).focus(window); + cx.notify(); })) .last(), diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index 868191f22d..0c7465ca26 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -100,7 +100,7 @@ async fn test_show_attach_modal_and_select_process( }, Candidate { pid: 3, - name: "non-fake-binary-1".into(), + name: "real-binary-1".into(), command: vec![], }, Candidate { @@ -108,7 +108,9 @@ async fn test_show_attach_modal_and_select_process( name: "fake-binary-2".into(), command: vec![], }, - ], + ] + .into_iter() + .collect(), true, window, cx, @@ -121,17 +123,30 @@ async fn test_show_attach_modal_and_select_process( cx.run_until_parked(); + // assert we got the expected processes + workspace + .update(cx, |_, window, cx| { + let names = + attach_modal.update(cx, |modal, cx| attach_modal::_process_names(&modal, cx)); + // Initially all processes are visible. + assert_eq!(3, names.len()); + attach_modal.update(cx, |this, cx| { + this.picker.update(cx, |this, cx| { + this.set_query("fakb", window, cx); + }) + }) + }) + .unwrap(); + cx.run_until_parked(); // assert we got the expected processes workspace .update(cx, |_, _, cx| { let names = attach_modal.update(cx, |modal, cx| attach_modal::_process_names(&modal, cx)); - - // we filtered out all processes that are not starting with `fake-binary` + // Initially all processes are visible. assert_eq!(2, names.len()); }) .unwrap(); - // select the only existing process cx.dispatch_action(Confirm); From 7e1b41924354c4297301a8bbe45b4b41d51aa26e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:35:24 -0300 Subject: [PATCH 73/75] markdown: Add ability to customize individual heading level (#28733) This PR adds a new field in the `MarkdownStyle` struct, `heading_level_styles`, allowing, via the newly added function `apply_heading_style` and struct `HeadingLevelStyles` to customize each individual heading level in Markdown rendering/styling function. Things like this should now be possible: ```rust MarkdownStyle { heading_level_styles: Some(HeadingLevelStyles { h1: Some(TextStyleRefinement { font_size: Some(rems(1.15).into()), ..Default::default() }), }), ..Default::default() } ``` Release Notes: - N/A --- crates/markdown/src/markdown.rs | 67 ++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 85dcefc40a..16f4f621e0 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -32,6 +32,17 @@ use crate::parser::CodeBlockKind; /// If the callback returns `None`, the default link style will be used. type LinkStyleCallback = Rc Option>; +/// Defines custom style refinements for each heading level (H1-H6) +#[derive(Clone, Default)] +pub struct HeadingLevelStyles { + pub h1: Option, + pub h2: Option, + pub h3: Option, + pub h4: Option, + pub h5: Option, + pub h6: Option, +} + #[derive(Clone)] pub struct MarkdownStyle { pub base_text_style: TextStyle, @@ -46,6 +57,7 @@ pub struct MarkdownStyle { pub syntax: Arc, pub selection_background_color: Hsla, pub heading: StyleRefinement, + pub heading_level_styles: Option, pub table_overflow_x_scroll: bool, } @@ -64,6 +76,7 @@ impl Default for MarkdownStyle { syntax: Arc::new(SyntaxTheme::default()), selection_background_color: Default::default(), heading: Default::default(), + heading_level_styles: None, table_overflow_x_scroll: false, } } @@ -628,17 +641,19 @@ impl Element for MarkdownElement { } MarkdownTag::Heading { level, .. } => { let mut heading = div().mb_2(); - heading = match level { - pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(), - pulldown_cmark::HeadingLevel::H2 => heading.text_2xl(), - pulldown_cmark::HeadingLevel::H3 => heading.text_xl(), - pulldown_cmark::HeadingLevel::H4 => heading.text_lg(), - _ => heading, - }; - heading.style().refine(&self.style.heading); - builder.push_text_style( - self.style.heading.text_style().clone().unwrap_or_default(), + + heading = apply_heading_style( + heading, + *level, + self.style.heading_level_styles.as_ref(), ); + + heading.style().refine(&self.style.heading); + + let text_style = + self.style.heading.text_style().clone().unwrap_or_default(); + + builder.push_text_style(text_style); builder.push_div(heading, range, markdown_end); } MarkdownTag::BlockQuote => { @@ -1043,6 +1058,38 @@ impl Element for MarkdownElement { } } +fn apply_heading_style( + mut heading: Div, + level: pulldown_cmark::HeadingLevel, + custom_styles: Option<&HeadingLevelStyles>, +) -> Div { + heading = match level { + pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(), + pulldown_cmark::HeadingLevel::H2 => heading.text_2xl(), + pulldown_cmark::HeadingLevel::H3 => heading.text_xl(), + pulldown_cmark::HeadingLevel::H4 => heading.text_lg(), + pulldown_cmark::HeadingLevel::H5 => heading.text_base(), + pulldown_cmark::HeadingLevel::H6 => heading.text_sm(), + }; + + if let Some(styles) = custom_styles { + let style_opt = match level { + pulldown_cmark::HeadingLevel::H1 => &styles.h1, + pulldown_cmark::HeadingLevel::H2 => &styles.h2, + pulldown_cmark::HeadingLevel::H3 => &styles.h3, + pulldown_cmark::HeadingLevel::H4 => &styles.h4, + pulldown_cmark::HeadingLevel::H5 => &styles.h5, + pulldown_cmark::HeadingLevel::H6 => &styles.h6, + }; + + if let Some(style) = style_opt { + heading.style().text = Some(style.clone()); + } + } + + heading +} + fn render_copy_code_block_button( id: usize, code: String, From e26f0a331f392d7ae12988d3744dc1a11bc8cb02 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 15 Apr 2025 06:42:31 -0600 Subject: [PATCH 74/75] agent: Make `ToolWorkingSet` an `Entity` (#28757) Motivation is to emit events when enabled tools change, want to use this in #28755 Release Notes: - N/A --- crates/agent/src/agent_diff.rs | 3 +- crates/agent/src/assistant_configuration.rs | 6 +- .../manage_profiles_modal.rs | 4 +- .../assistant_configuration/tool_picker.rs | 6 +- crates/agent/src/assistant_panel.rs | 2 +- crates/agent/src/profile_selector.rs | 2 +- crates/agent/src/thread.rs | 16 +- crates/agent/src/thread_store.rs | 131 ++++++------ crates/agent/src/tool_use.rs | 22 +- crates/assistant_tool/src/tool_working_set.rs | 189 +++++++----------- crates/eval/src/example.rs | 4 +- 11 files changed, 183 insertions(+), 202 deletions(-) diff --git a/crates/agent/src/agent_diff.rs b/crates/agent/src/agent_diff.rs index 13f2f991fb..8fdcbbcb58 100644 --- a/crates/agent/src/agent_diff.rs +++ b/crates/agent/src/agent_diff.rs @@ -894,6 +894,7 @@ mod tests { use super::*; use crate::{ThreadStore, thread_store}; use assistant_settings::AssistantSettings; + use assistant_tool::ToolWorkingSet; use context_server::ContextServerSettings; use editor::EditorSettings; use gpui::TestAppContext; @@ -937,7 +938,7 @@ mod tests { .update(|cx| { ThreadStore::load( project.clone(), - Arc::default(), + cx.new(|_| ToolWorkingSet::default()), Arc::new(PromptBuilder::new(None).unwrap()), cx, ) diff --git a/crates/agent/src/assistant_configuration.rs b/crates/agent/src/assistant_configuration.rs index f464abe1b0..7616b1f8b0 100644 --- a/crates/agent/src/assistant_configuration.rs +++ b/crates/agent/src/assistant_configuration.rs @@ -29,7 +29,7 @@ pub struct AssistantConfiguration { configuration_views_by_provider: HashMap, context_server_manager: Entity, expanded_context_server_tools: HashMap, bool>, - tools: Arc, + tools: Entity, _registry_subscription: Subscription, } @@ -37,7 +37,7 @@ impl AssistantConfiguration { pub fn new( fs: Arc, context_server_manager: Entity, - tools: Arc, + tools: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -226,7 +226,7 @@ impl AssistantConfiguration { fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { let context_servers = self.context_server_manager.read(cx).all_servers().clone(); - let tools_by_source = self.tools.tools_by_source(cx); + let tools_by_source = self.tools.read(cx).tools_by_source(cx); let empty = Vec::new(); const SUBHEADING: &str = "Connect to context servers via the Model Context Protocol either via Zed extensions or directly."; diff --git a/crates/agent/src/assistant_configuration/manage_profiles_modal.rs b/crates/agent/src/assistant_configuration/manage_profiles_modal.rs index a4c72cbac9..6f5172a8d4 100644 --- a/crates/agent/src/assistant_configuration/manage_profiles_modal.rs +++ b/crates/agent/src/assistant_configuration/manage_profiles_modal.rs @@ -84,7 +84,7 @@ pub struct NewProfileMode { pub struct ManageProfilesModal { fs: Arc, - tools: Arc, + tools: Entity, thread_store: WeakEntity, focus_handle: FocusHandle, mode: Mode, @@ -117,7 +117,7 @@ impl ManageProfilesModal { pub fn new( fs: Arc, - tools: Arc, + tools: Entity, thread_store: WeakEntity, window: &mut Window, cx: &mut Context, diff --git a/crates/agent/src/assistant_configuration/tool_picker.rs b/crates/agent/src/assistant_configuration/tool_picker.rs index eabd9e172b..2b105e87a2 100644 --- a/crates/agent/src/assistant_configuration/tool_picker.rs +++ b/crates/agent/src/assistant_configuration/tool_picker.rs @@ -60,7 +60,7 @@ pub struct ToolPickerDelegate { impl ToolPickerDelegate { pub fn new( fs: Arc, - tool_set: Arc, + tool_set: Entity, thread_store: WeakEntity, profile_id: AgentProfileId, profile: AgentProfile, @@ -68,7 +68,7 @@ impl ToolPickerDelegate { ) -> Self { let mut tool_entries = Vec::new(); - for (source, tools) in tool_set.tools_by_source(cx) { + for (source, tools) in tool_set.read(cx).tools_by_source(cx) { tool_entries.extend(tools.into_iter().map(|tool| ToolEntry { name: tool.name().into(), source: source.clone(), @@ -192,7 +192,7 @@ impl PickerDelegate for ToolPickerDelegate { if active_profile_id == &self.profile_id { self.thread_store .update(cx, |this, cx| { - this.load_profile(&self.profile, cx); + this.load_profile(self.profile.clone(), cx); }) .log_err(); } diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index b5dcee9dc2..fa953d93a8 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -203,7 +203,7 @@ impl AssistantPanel { cx: AsyncWindowContext, ) -> Task>> { cx.spawn(async move |cx| { - let tools = Arc::new(ToolWorkingSet::default()); + let tools = cx.new(|_| ToolWorkingSet::default())?; let thread_store = workspace .update(cx, |workspace, cx| { let project = workspace.project().clone(); diff --git a/crates/agent/src/profile_selector.rs b/crates/agent/src/profile_selector.rs index dfcafba5fc..c033bf9c58 100644 --- a/crates/agent/src/profile_selector.rs +++ b/crates/agent/src/profile_selector.rs @@ -86,7 +86,7 @@ impl ProfileSelector { thread_store .update(cx, |this, cx| { - this.load_profile_by_id(&profile_id, cx); + this.load_profile_by_id(profile_id.clone(), cx); }) .log_err(); } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index fe4844bd86..ada5c068a7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -254,7 +254,7 @@ pub struct Thread { pending_completions: Vec, project: Entity, prompt_builder: Arc, - tools: Arc, + tools: Entity, tool_use: ToolUseState, action_log: Entity, last_restore_checkpoint: Option, @@ -278,7 +278,7 @@ pub struct ExceededWindowError { impl Thread { pub fn new( project: Entity, - tools: Arc, + tools: Entity, prompt_builder: Arc, system_prompt: SharedProjectContext, cx: &mut Context, @@ -322,7 +322,7 @@ impl Thread { id: ThreadId, serialized: SerializedThread, project: Entity, - tools: Arc, + tools: Entity, prompt_builder: Arc, project_context: SharedProjectContext, cx: &mut Context, @@ -458,7 +458,7 @@ impl Thread { !self.pending_completions.is_empty() || !self.all_tools_finished() } - pub fn tools(&self) -> &Arc { + pub fn tools(&self) -> &Entity { &self.tools } @@ -846,6 +846,7 @@ impl Thread { let mut tools = Vec::new(); tools.extend( self.tools() + .read(cx) .enabled_tools(cx) .into_iter() .filter_map(|tool| { @@ -1354,7 +1355,7 @@ impl Thread { .collect::>(); for tool_use in pending_tool_uses.iter() { - if let Some(tool) = self.tools.tool(&tool_use.name, cx) { + if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { if tool.needs_confirmation(&tool_use.input, cx) && !AssistantSettings::get_global(cx).always_allow_tool_actions { @@ -1406,7 +1407,7 @@ impl Thread { ) -> Task<()> { let tool_name: Arc = tool.name().into(); - let run_tool = if self.tools.is_disabled(&tool.source(), &tool_name) { + let run_tool = if self.tools.read(cx).is_disabled(&tool.source(), &tool_name) { Task::ready(Err(anyhow!("tool is disabled: {tool_name}"))) } else { tool.run( @@ -1521,6 +1522,7 @@ impl Thread { let enabled_tool_names: Vec = self .tools() + .read(cx) .enabled_tools(cx) .iter() .map(|tool| tool.name().to_string()) @@ -2341,7 +2343,7 @@ fn main() {{ .update(|_, cx| { ThreadStore::load( project.clone(), - Arc::default(), + cx.new(|_| ToolWorkingSet::default()), Arc::new(PromptBuilder::new(None).unwrap()), cx, ) diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index ebb673a86f..6fb0f6c7a2 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -56,7 +56,7 @@ impl SharedProjectContext { pub struct ThreadStore { project: Entity, - tools: Arc, + tools: Entity, prompt_builder: Arc, context_server_manager: Entity, context_server_tool_ids: HashMap, Vec>, @@ -74,7 +74,7 @@ impl EventEmitter for ThreadStore {} impl ThreadStore { pub fn load( project: Entity, - tools: Arc, + tools: Entity, prompt_builder: Arc, cx: &mut App, ) -> Task> { @@ -88,7 +88,7 @@ impl ThreadStore { fn new( project: Entity, - tools: Arc, + tools: Entity, prompt_builder: Arc, cx: &mut Context, ) -> Self { @@ -248,7 +248,7 @@ impl ThreadStore { self.context_server_manager.clone() } - pub fn tools(&self) -> Arc { + pub fn tools(&self) -> Entity { self.tools.clone() } @@ -355,52 +355,60 @@ impl ThreadStore { }) } - fn load_default_profile(&self, cx: &Context) { + fn load_default_profile(&self, cx: &mut Context) { let assistant_settings = AssistantSettings::get_global(cx); - self.load_profile_by_id(&assistant_settings.default_profile, cx); + self.load_profile_by_id(assistant_settings.default_profile.clone(), cx); } - pub fn load_profile_by_id(&self, profile_id: &AgentProfileId, cx: &Context) { + pub fn load_profile_by_id(&self, profile_id: AgentProfileId, cx: &mut Context) { let assistant_settings = AssistantSettings::get_global(cx); - if let Some(profile) = assistant_settings.profiles.get(profile_id) { - self.load_profile(profile, cx); + if let Some(profile) = assistant_settings.profiles.get(&profile_id) { + self.load_profile(profile.clone(), cx); } } - pub fn load_profile(&self, profile: &AgentProfile, cx: &Context) { - self.tools.disable_all_tools(); - self.tools.enable( - ToolSource::Native, - &profile - .tools - .iter() - .filter_map(|(tool, enabled)| enabled.then(|| tool.clone())) - .collect::>(), - ); + pub fn load_profile(&self, profile: AgentProfile, cx: &mut Context) { + self.tools.update(cx, |tools, cx| { + tools.disable_all_tools(cx); + tools.enable( + ToolSource::Native, + &profile + .tools + .iter() + .filter_map(|(tool, enabled)| enabled.then(|| tool.clone())) + .collect::>(), + cx, + ); + }); if profile.enable_all_context_servers { for context_server in self.context_server_manager.read(cx).all_servers() { - self.tools.enable_source( - ToolSource::ContextServer { - id: context_server.id().into(), - }, - cx, - ); + self.tools.update(cx, |tools, cx| { + tools.enable_source( + ToolSource::ContextServer { + id: context_server.id().into(), + }, + cx, + ); + }); } } else { for (context_server_id, preset) in &profile.context_servers { - self.tools.enable( - ToolSource::ContextServer { - id: context_server_id.clone().into(), - }, - &preset - .tools - .iter() - .filter_map(|(tool, enabled)| enabled.then(|| tool.clone())) - .collect::>(), - ) + self.tools.update(cx, |tools, cx| { + tools.enable( + ToolSource::ContextServer { + id: context_server_id.clone().into(), + }, + &preset + .tools + .iter() + .filter_map(|(tool, enabled)| enabled.then(|| tool.clone())) + .collect::>(), + cx, + ) + }) } } } @@ -434,29 +442,36 @@ impl ThreadStore { if protocol.capable(context_server::protocol::ServerCapability::Tools) { if let Some(tools) = protocol.list_tools().await.log_err() { - let tool_ids = tools - .tools - .into_iter() - .map(|tool| { - log::info!( - "registering context server tool: {:?}", - tool.name - ); - tool_working_set.insert(Arc::new( - ContextServerTool::new( - context_server_manager.clone(), - server.id(), - tool, - ), - )) + let tool_ids = tool_working_set + .update(cx, |tool_working_set, _| { + tools + .tools + .into_iter() + .map(|tool| { + log::info!( + "registering context server tool: {:?}", + tool.name + ); + tool_working_set.insert(Arc::new( + ContextServerTool::new( + context_server_manager.clone(), + server.id(), + tool, + ), + )) + }) + .collect::>() }) - .collect::>(); + .log_err(); - this.update(cx, |this, cx| { - this.context_server_tool_ids.insert(server_id, tool_ids); - this.load_default_profile(cx); - }) - .log_err(); + if let Some(tool_ids) = tool_ids { + this.update(cx, |this, cx| { + this.context_server_tool_ids + .insert(server_id, tool_ids); + this.load_default_profile(cx); + }) + .log_err(); + } } } } @@ -466,7 +481,9 @@ impl ThreadStore { } context_server::manager::Event::ServerStopped { server_id } => { if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) { - tool_working_set.remove(&tool_ids); + tool_working_set.update(cx, |tool_working_set, _| { + tool_working_set.remove(&tool_ids); + }); self.load_default_profile(cx); } } diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs index 285e72d993..93576d57a3 100644 --- a/crates/agent/src/tool_use.rs +++ b/crates/agent/src/tool_use.rs @@ -5,7 +5,7 @@ use assistant_tool::{Tool, ToolWorkingSet}; use collections::HashMap; use futures::FutureExt as _; use futures::future::Shared; -use gpui::{App, SharedString, Task}; +use gpui::{App, Entity, SharedString, Task}; use language_model::{ LanguageModelRegistry, LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, @@ -49,7 +49,7 @@ impl ToolUseStatus { } pub struct ToolUseState { - tools: Arc, + tools: Entity, tool_uses_by_assistant_message: HashMap>, tool_uses_by_user_message: HashMap>, tool_results: HashMap, @@ -59,7 +59,7 @@ pub struct ToolUseState { pub const USING_TOOL_MARKER: &str = ""; impl ToolUseState { - pub fn new(tools: Arc) -> Self { + pub fn new(tools: Entity) -> Self { Self { tools, tool_uses_by_assistant_message: HashMap::default(), @@ -73,7 +73,7 @@ impl ToolUseState { /// /// Accepts a function to filter the tools that should be used to populate the state. pub fn from_serialized_messages( - tools: Arc, + tools: Entity, messages: &[SerializedMessage], mut filter_by_tool_name: impl FnMut(&str) -> bool, ) -> Self { @@ -199,12 +199,12 @@ impl ToolUseState { } })(); - let (icon, needs_confirmation) = if let Some(tool) = self.tools.tool(&tool_use.name, cx) - { - (tool.icon(), tool.needs_confirmation(&tool_use.input, cx)) - } else { - (IconName::Cog, false) - }; + let (icon, needs_confirmation) = + if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { + (tool.icon(), tool.needs_confirmation(&tool_use.input, cx)) + } else { + (IconName::Cog, false) + }; tool_uses.push(ToolUse { id: tool_use.id.clone(), @@ -226,7 +226,7 @@ impl ToolUseState { input: &serde_json::Value, cx: &App, ) -> SharedString { - if let Some(tool) = self.tools.tool(tool_name, cx) { + if let Some(tool) = self.tools.read(cx).tool(tool_name, cx) { tool.ui_text(input).into() } else { format!("Unknown tool {tool_name:?}").into() diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs index 97060cfdad..c7e20d3517 100644 --- a/crates/assistant_tool/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -1,8 +1,7 @@ use std::sync::Arc; use collections::{HashMap, HashSet, IndexMap}; -use gpui::App; -use parking_lot::Mutex; +use gpui::{App, Context, EventEmitter}; use crate::{Tool, ToolRegistry, ToolSource}; @@ -12,11 +11,6 @@ pub struct ToolId(usize); /// A working set of tools for use in one instance of the Assistant Panel. #[derive(Default)] pub struct ToolWorkingSet { - state: Mutex, -} - -#[derive(Default)] -struct WorkingSetState { context_server_tools_by_id: HashMap>, context_server_tools_by_name: HashMap>, enabled_sources: HashSet, @@ -24,99 +18,27 @@ struct WorkingSetState { next_tool_id: ToolId, } +pub enum ToolWorkingSetEvent { + EnabledToolsChanged, +} + +impl EventEmitter for ToolWorkingSet {} + impl ToolWorkingSet { pub fn tool(&self, name: &str, cx: &App) -> Option> { - self.state - .lock() - .context_server_tools_by_name + self.context_server_tools_by_name .get(name) .cloned() .or_else(|| ToolRegistry::global(cx).tool(name)) } pub fn tools(&self, cx: &App) -> Vec> { - self.state.lock().tools(cx) - } - - pub fn tools_by_source(&self, cx: &App) -> IndexMap>> { - self.state.lock().tools_by_source(cx) - } - - pub fn enabled_tools(&self, cx: &App) -> Vec> { - self.state.lock().enabled_tools(cx) - } - - pub fn disable_all_tools(&self) { - let mut state = self.state.lock(); - state.disable_all_tools(); - } - - pub fn enable_source(&self, source: ToolSource, cx: &App) { - let mut state = self.state.lock(); - state.enable_source(source, cx); - } - - pub fn disable_source(&self, source: &ToolSource) { - let mut state = self.state.lock(); - state.disable_source(source); - } - - pub fn insert(&self, tool: Arc) -> ToolId { - let mut state = self.state.lock(); - let tool_id = state.next_tool_id; - state.next_tool_id.0 += 1; - state - .context_server_tools_by_id - .insert(tool_id, tool.clone()); - state.tools_changed(); - tool_id - } - - pub fn is_enabled(&self, source: &ToolSource, name: &Arc) -> bool { - self.state.lock().is_enabled(source, name) - } - - pub fn is_disabled(&self, source: &ToolSource, name: &Arc) -> bool { - self.state.lock().is_disabled(source, name) - } - - pub fn enable(&self, source: ToolSource, tools_to_enable: &[Arc]) { - let mut state = self.state.lock(); - state.enable(source, tools_to_enable); - } - - pub fn disable(&self, source: ToolSource, tools_to_disable: &[Arc]) { - let mut state = self.state.lock(); - state.disable(source, tools_to_disable); - } - - pub fn remove(&self, tool_ids_to_remove: &[ToolId]) { - let mut state = self.state.lock(); - state - .context_server_tools_by_id - .retain(|id, _| !tool_ids_to_remove.contains(id)); - state.tools_changed(); - } -} - -impl WorkingSetState { - fn tools_changed(&mut self) { - self.context_server_tools_by_name.clear(); - self.context_server_tools_by_name.extend( - self.context_server_tools_by_id - .values() - .map(|tool| (tool.name(), tool.clone())), - ); - } - - fn tools(&self, cx: &App) -> Vec> { let mut tools = ToolRegistry::global(cx).tools(); tools.extend(self.context_server_tools_by_id.values().cloned()); - tools } - fn tools_by_source(&self, cx: &App) -> IndexMap>> { + pub fn tools_by_source(&self, cx: &App) -> IndexMap>> { let mut tools_by_source = IndexMap::default(); for tool in self.tools(cx) { @@ -135,7 +57,7 @@ impl WorkingSetState { tools_by_source } - fn enabled_tools(&self, cx: &App) -> Vec> { + pub fn enabled_tools(&self, cx: &App) -> Vec> { let all_tools = self.tools(cx); all_tools @@ -144,31 +66,12 @@ impl WorkingSetState { .collect() } - fn is_enabled(&self, source: &ToolSource, name: &Arc) -> bool { - self.enabled_tools_by_source - .get(source) - .map_or(false, |enabled_tools| enabled_tools.contains(name)) + pub fn disable_all_tools(&mut self, cx: &mut Context) { + self.enabled_tools_by_source.clear(); + cx.emit(ToolWorkingSetEvent::EnabledToolsChanged); } - fn is_disabled(&self, source: &ToolSource, name: &Arc) -> bool { - !self.is_enabled(source, name) - } - - fn enable(&mut self, source: ToolSource, tools_to_enable: &[Arc]) { - self.enabled_tools_by_source - .entry(source) - .or_default() - .extend(tools_to_enable.into_iter().cloned()); - } - - fn disable(&mut self, source: ToolSource, tools_to_disable: &[Arc]) { - self.enabled_tools_by_source - .entry(source) - .or_default() - .retain(|name| !tools_to_disable.contains(name)); - } - - fn enable_source(&mut self, source: ToolSource, cx: &App) { + pub fn enable_source(&mut self, source: ToolSource, cx: &mut Context) { self.enabled_sources.insert(source.clone()); let tools_by_source = self.tools_by_source(cx); @@ -181,14 +84,72 @@ impl WorkingSetState { .collect::>(), ); } + cx.emit(ToolWorkingSetEvent::EnabledToolsChanged); } - fn disable_source(&mut self, source: &ToolSource) { + pub fn disable_source(&mut self, source: &ToolSource, cx: &mut Context) { self.enabled_sources.remove(source); self.enabled_tools_by_source.remove(source); + cx.emit(ToolWorkingSetEvent::EnabledToolsChanged); } - fn disable_all_tools(&mut self) { - self.enabled_tools_by_source.clear(); + pub fn insert(&mut self, tool: Arc) -> ToolId { + let tool_id = self.next_tool_id; + self.next_tool_id.0 += 1; + self.context_server_tools_by_id + .insert(tool_id, tool.clone()); + self.tools_changed(); + tool_id + } + + pub fn is_enabled(&self, source: &ToolSource, name: &Arc) -> bool { + self.enabled_tools_by_source + .get(source) + .map_or(false, |enabled_tools| enabled_tools.contains(name)) + } + + pub fn is_disabled(&self, source: &ToolSource, name: &Arc) -> bool { + !self.is_enabled(source, name) + } + + pub fn enable( + &mut self, + source: ToolSource, + tools_to_enable: &[Arc], + cx: &mut Context, + ) { + self.enabled_tools_by_source + .entry(source) + .or_default() + .extend(tools_to_enable.into_iter().cloned()); + cx.emit(ToolWorkingSetEvent::EnabledToolsChanged); + } + + pub fn disable( + &mut self, + source: ToolSource, + tools_to_disable: &[Arc], + cx: &mut Context, + ) { + self.enabled_tools_by_source + .entry(source) + .or_default() + .retain(|name| !tools_to_disable.contains(name)); + cx.emit(ToolWorkingSetEvent::EnabledToolsChanged); + } + + pub fn remove(&mut self, tool_ids_to_remove: &[ToolId]) { + self.context_server_tools_by_id + .retain(|id, _| !tool_ids_to_remove.contains(id)); + self.tools_changed(); + } + + fn tools_changed(&mut self) { + self.context_server_tools_by_name.clear(); + self.context_server_tools_by_name.extend( + self.context_server_tools_by_id + .values() + .map(|tool| (tool.name(), tool.clone())), + ); } } diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 531b1ea275..1d74443b1a 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -6,7 +6,7 @@ use collections::HashMap; use dap::DapRegistry; use futures::channel::mpsc; use futures::{FutureExt, StreamExt as _, select_biased}; -use gpui::{App, AsyncApp, Entity, Task}; +use gpui::{App, AppContext as _, AsyncApp, Entity, Task}; use handlebars::Handlebars; use language::{DiagnosticSeverity, OffsetRangeExt}; use language_model::{ @@ -181,7 +181,7 @@ impl Example { project.create_worktree(&worktree_path, true, cx) }); - let tools = Arc::new(ToolWorkingSet::default()); + let tools = cx.new(|_| ToolWorkingSet::default()); let thread_store = ThreadStore::load(project.clone(), tools, app_state.prompt_builder.clone(), cx); let this = self.clone(); From 2b89b97cd1dd6474762a9b35b9fbf044014e2e82 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:20:35 -0300 Subject: [PATCH 75/75] agent: Adjust markdown heading sizes (#28759) Adjust the heading sizes for the Agent Panel so they're not aggressively huge. Release Notes: - N/A --- crates/agent/src/active_thread.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index bf50789646..f59fabe139 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -23,7 +23,7 @@ use gpui::{ use language::{Buffer, LanguageRegistry}; use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role, StopReason}; use markdown::parser::{CodeBlockKind, CodeBlockMetadata}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle, ParsedMarkdown}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle, ParsedMarkdown}; use project::ProjectItem as _; use rope::Point; use settings::{Settings as _, update_settings_file}; @@ -175,11 +175,37 @@ fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { }); MarkdownStyle { - base_text_style: text_style, + base_text_style: text_style.clone(), syntax: cx.theme().syntax().clone(), selection_background_color: cx.theme().players().local().selection, code_block_overflow_x_scroll: true, table_overflow_x_scroll: true, + heading_level_styles: Some(HeadingLevelStyles { + h1: Some(TextStyleRefinement { + font_size: Some(rems(1.15).into()), + ..Default::default() + }), + h2: Some(TextStyleRefinement { + font_size: Some(rems(1.1).into()), + ..Default::default() + }), + h3: Some(TextStyleRefinement { + font_size: Some(rems(1.05).into()), + ..Default::default() + }), + h4: Some(TextStyleRefinement { + font_size: Some(rems(1.).into()), + ..Default::default() + }), + h5: Some(TextStyleRefinement { + font_size: Some(rems(0.95).into()), + ..Default::default() + }), + h6: Some(TextStyleRefinement { + font_size: Some(rems(0.875).into()), + ..Default::default() + }), + }), code_block: StyleRefinement { padding: EdgesRefinement { top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),