Bring back semantic search UI (#3623)

Piotr Osiewicz created

- [x] Add "Include ignored" to filters
- [x] There seems to be a bug (seemingly unrelated to this PR) where we
reindex the project on each launch. Edit: Seems to be the case on Zed1
as well if the indexing is interrupted.

Release Notes:
- N/A

Change summary

crates/search2/src/project_search.rs | 411 ++++++++++++-----------------
crates/search2/src/search.rs         |   5 
2 files changed, 181 insertions(+), 235 deletions(-)

Detailed changes

crates/search2/src/project_search.rs 🔗

@@ -1,7 +1,8 @@
 use crate::{
-    history::SearchHistory, mode::SearchMode, ActivateRegexMode, ActivateTextMode, CycleMode,
-    NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
-    SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleReplace, ToggleWholeWord,
+    history::SearchHistory, mode::SearchMode, ActivateRegexMode, ActivateSemanticMode,
+    ActivateTextMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext,
+    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleIncludeIgnored,
+    ToggleReplace, ToggleWholeWord,
 };
 use anyhow::{Context as _, Result};
 use collections::HashMap;
@@ -29,7 +30,7 @@ use std::{
     mem,
     ops::{Not, Range},
     path::PathBuf,
-    time::Duration,
+    time::{Duration, Instant},
 };
 
 use ui::{
@@ -283,196 +284,86 @@ impl Render for ProjectSearchView {
             let model = self.model.read(cx);
             let has_no_results = model.no_results.unwrap_or(false);
             let is_search_underway = model.pending_search.is_some();
-            let major_text = if is_search_underway {
+            let mut major_text = if is_search_underway {
                 Label::new("Searching...")
             } else if has_no_results {
-                Label::new("No results for a given query")
+                Label::new("No results")
             } else {
                 Label::new(format!("{} search all files", self.current_mode.label()))
             };
+
+            let mut show_minor_text = true;
+            let semantic_status = self.semantic_state.as_ref().and_then(|semantic| {
+                let status = semantic.index_status;
+                                match status {
+                                    SemanticIndexStatus::NotAuthenticated => {
+                                        major_text = Label::new("Not Authenticated");
+                                        show_minor_text = false;
+                                        Some(
+                                            "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables. If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string())
+                                    }
+                                    SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()),
+                                    SemanticIndexStatus::Indexing {
+                                        remaining_files,
+                                        rate_limit_expiry,
+                                    } => {
+                                        if remaining_files == 0 {
+                                            Some("Indexing...".to_string())
+                                        } else {
+                                            if let Some(rate_limit_expiry) = rate_limit_expiry {
+                                                let remaining_seconds =
+                                                    rate_limit_expiry.duration_since(Instant::now());
+                                                if remaining_seconds > Duration::from_secs(0) {
+                                                    Some(format!(
+                                                        "Remaining files to index (rate limit resets in {}s): {}",
+                                                        remaining_seconds.as_secs(),
+                                                        remaining_files
+                                                    ))
+                                                } else {
+                                                    Some(format!("Remaining files to index: {}", remaining_files))
+                                                }
+                                            } else {
+                                                Some(format!("Remaining files to index: {}", remaining_files))
+                                            }
+                                        }
+                                    }
+                                    SemanticIndexStatus::NotIndexed => None,
+                                }
+            });
             let major_text = div().justify_center().max_w_96().child(major_text);
-            let middle_text = div()
-                .items_center()
-                .max_w_96()
-                .child(Label::new(self.landing_text_minor()).size(LabelSize::Small));
+
+            let minor_text: Option<SharedString> = if let Some(no_results) = model.no_results {
+                if model.pending_search.is_none() && no_results {
+                    Some("No results found in this project for the provided query".into())
+                } else {
+                    None
+                }
+            } else {
+                if let Some(mut semantic_status) = semantic_status {
+                    semantic_status.extend(self.landing_text_minor().chars());
+                    Some(semantic_status.into())
+                } else {
+                    Some(self.landing_text_minor())
+                }
+            };
+            let minor_text = minor_text.map(|text| {
+                div()
+                    .items_center()
+                    .max_w_96()
+                    .child(Label::new(text).size(LabelSize::Small))
+            });
             v_stack().flex_1().size_full().justify_center().child(
                 h_stack()
                     .size_full()
                     .justify_center()
                     .child(h_stack().flex_1())
-                    .child(v_stack().child(major_text).child(middle_text))
+                    .child(v_stack().child(major_text).children(minor_text))
                     .child(h_stack().flex_1()),
             )
         }
     }
 }
 
-// impl Entity for ProjectSearchView {
-//     type Event = ViewEvent;
-// }
-
-// impl View for ProjectSearchView {
-//     fn ui_name() -> &'static str {
-//         "ProjectSearchView"
-//     }
-
-//     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-//         let model = &self.model.read(cx);
-//         if model.match_ranges.is_empty() {
-//             enum Status {}
-
-//             let theme = theme::current(cx).clone();
-
-//             // If Search is Active -> Major: Searching..., Minor: None
-//             // If Semantic -> Major: "Search using Natural Language", Minor: {Status}/n{ex...}/n{ex...}
-//             // If Regex -> Major: "Search using Regex", Minor: {ex...}
-//             // If Text -> Major: "Text search all files and folders", Minor: {...}
-
-//             let current_mode = self.current_mode;
-//             let mut major_text = if model.pending_search.is_some() {
-//                 Cow::Borrowed("Searching...")
-//             } else if model.no_results.is_some_and(|v| v) {
-//                 Cow::Borrowed("No Results")
-//             } else {
-//                 match current_mode {
-//                     SearchMode::Text => Cow::Borrowed("Text search all files and folders"),
-//                     SearchMode::Semantic => {
-//                         Cow::Borrowed("Search all code objects using Natural Language")
-//                     }
-//                     SearchMode::Regex => Cow::Borrowed("Regex search all files and folders"),
-//                 }
-//             };
-
-//             let mut show_minor_text = true;
-//             let semantic_status = self.semantic_state.as_ref().and_then(|semantic| {
-//                 let status = semantic.index_status;
-//                 match status {
-//                     SemanticIndexStatus::NotAuthenticated => {
-//                         major_text = Cow::Borrowed("Not Authenticated");
-//                         show_minor_text = false;
-//                         Some(vec![
-//                             "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables."
-//                                 .to_string(), "If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string()])
-//                     }
-//                     SemanticIndexStatus::Indexed => Some(vec!["Indexing complete".to_string()]),
-//                     SemanticIndexStatus::Indexing {
-//                         remaining_files,
-//                         rate_limit_expiry,
-//                     } => {
-//                         if remaining_files == 0 {
-//                             Some(vec![format!("Indexing...")])
-//                         } else {
-//                             if let Some(rate_limit_expiry) = rate_limit_expiry {
-//                                 let remaining_seconds =
-//                                     rate_limit_expiry.duration_since(Instant::now());
-//                                 if remaining_seconds > Duration::from_secs(0) {
-//                                     Some(vec![format!(
-//                                         "Remaining files to index (rate limit resets in {}s): {}",
-//                                         remaining_seconds.as_secs(),
-//                                         remaining_files
-//                                     )])
-//                                 } else {
-//                                     Some(vec![format!("Remaining files to index: {}", remaining_files)])
-//                                 }
-//                             } else {
-//                                 Some(vec![format!("Remaining files to index: {}", remaining_files)])
-//                             }
-//                         }
-//                     }
-//                     SemanticIndexStatus::NotIndexed => None,
-//                 }
-//             });
-
-//             let minor_text = if let Some(no_results) = model.no_results {
-//                 if model.pending_search.is_none() && no_results {
-//                     vec!["No results found in this project for the provided query".to_owned()]
-//                 } else {
-//                     vec![]
-//                 }
-//             } else {
-//                 match current_mode {
-//                     SearchMode::Semantic => {
-//                         let mut minor_text: Vec<String> = Vec::new();
-//                         minor_text.push("".into());
-//                         if let Some(semantic_status) = semantic_status {
-//                             minor_text.extend(semantic_status);
-//                         }
-//                         if show_minor_text {
-//                             minor_text
-//                                 .push("Simply explain the code you are looking to find.".into());
-//                             minor_text.push(
-//                                 "ex. 'prompt user for permissions to index their project'".into(),
-//                             );
-//                         }
-//                         minor_text
-//                     }
-//                     _ => vec![
-//                         "".to_owned(),
-//                         "Include/exclude specific paths with the filter option.".to_owned(),
-//                         "Matching exact word and/or casing is available too.".to_owned(),
-//                     ],
-//                 }
-//             };
-
-//             MouseEventHandler::new::<Status, _>(0, cx, |_, _| {
-//                 Flex::column()
-//                     .with_child(Flex::column().contained().flex(1., true))
-//                     .with_child(
-//                         Flex::column()
-//                             .align_children_center()
-//                             .with_child(Label::new(
-//                                 major_text,
-//                                 theme.search.major_results_status.clone(),
-//                             ))
-//                             .with_children(
-//                                 minor_text.into_iter().map(|x| {
-//                                     Label::new(x, theme.search.minor_results_status.clone())
-//                                 }),
-//                             )
-//                             .aligned()
-//                             .top()
-//                             .contained()
-//                             .flex(7., true),
-//                     )
-//                     .contained()
-//                     .with_background_color(theme.editor.background)
-//             })
-//             .on_down(MouseButton::Left, |_, _, cx| {
-//                 cx.focus_parent();
-//             })
-//             .into_any_named("project search view")
-//         } else {
-//             ChildView::new(&self.results_editor, cx)
-//                 .flex(1., true)
-//                 .into_any_named("project search view")
-//         }
-//     }
-
-//     fn focus_in(&mut self, _: AnyView, cx: &mut ViewContext<Self>) {
-//         let handle = cx.weak_handle();
-//         cx.update_global(|state: &mut ActiveSearches, cx| {
-//             state
-//                 .0
-//                 .insert(self.model.read(cx).project.downgrade(), handle)
-//         });
-
-//         cx.update_global(|state: &mut ActiveSettings, cx| {
-//             state.0.insert(
-//                 self.model.read(cx).project.downgrade(),
-//                 self.current_settings(),
-//             );
-//         });
-
-//         if cx.is_self_focused() {
-//             if self.query_editor_was_focused {
-//                 cx.focus(&self.query_editor);
-//             } else {
-//                 cx.focus(&self.results_editor);
-//             }
-//         }
-//     }
-// }
-
 impl FocusableView for ProjectSearchView {
     fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
         self.results_editor.focus_handle(cx)
@@ -1256,7 +1147,7 @@ impl ProjectSearchView {
     fn landing_text_minor(&self) -> SharedString {
         match self.current_mode {
             SearchMode::Text | SearchMode::Regex => "Include/exclude specific paths with the filter option. Matching exact word and/or casing is available too.".into(),
-            SearchMode::Semantic => ".Simply explain the code you are looking to find. ex. 'prompt user for permissions to index their project'".into()
+            SearchMode::Semantic => "\nSimply explain the code you are looking to find. ex. 'prompt user for permissions to index their project'".into()
         }
     }
 }
@@ -1277,8 +1168,8 @@ impl ProjectSearchBar {
     fn cycle_mode(&self, _: &CycleMode, cx: &mut ViewContext<Self>) {
         if let Some(view) = self.active_project_search.as_ref() {
             view.update(cx, |this, cx| {
-                // todo: po: 2nd argument of `next_mode` should be `SemanticIndex::enabled(cx))`, but we need to flesh out port of semantic_index first.
-                let new_mode = crate::mode::next_mode(&this.current_mode, false);
+                let new_mode =
+                    crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx));
                 this.activate_search_mode(new_mode, cx);
                 let editor_handle = this.query_editor.focus_handle(cx);
                 cx.focus(&editor_handle);
@@ -1548,7 +1439,7 @@ impl Render for ProjectSearchBar {
             });
         }
         let search = search.read(cx);
-
+        let semantic_is_available = SemanticIndex::enabled(cx);
         let query_column = v_stack()
             //.flex_1()
             .child(
@@ -1578,42 +1469,51 @@ impl Render for ProjectSearchBar {
                                             .unwrap_or_default(),
                                     ),
                             )
-                            .child(
-                                IconButton::new(
-                                    "project-search-case-sensitive",
-                                    Icon::CaseSensitive,
-                                )
-                                .tooltip(|cx| {
-                                    Tooltip::for_action(
-                                        "Toggle case sensitive",
-                                        &ToggleCaseSensitive,
-                                        cx,
+                            .when(search.current_mode != SearchMode::Semantic, |this| {
+                                this.child(
+                                    IconButton::new(
+                                        "project-search-case-sensitive",
+                                        Icon::CaseSensitive,
                                     )
-                                })
-                                .selected(self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx))
-                                .on_click(cx.listener(
-                                    |this, _, cx| {
-                                        this.toggle_search_option(
-                                            SearchOptions::CASE_SENSITIVE,
-                                            cx,
-                                        );
-                                    },
-                                )),
-                            )
-                            .child(
-                                IconButton::new("project-search-whole-word", Icon::WholeWord)
                                     .tooltip(|cx| {
                                         Tooltip::for_action(
-                                            "Toggle whole word",
-                                            &ToggleWholeWord,
+                                            "Toggle case sensitive",
+                                            &ToggleCaseSensitive,
                                             cx,
                                         )
                                     })
-                                    .selected(self.is_option_enabled(SearchOptions::WHOLE_WORD, cx))
-                                    .on_click(cx.listener(|this, _, cx| {
-                                        this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
-                                    })),
-                            ),
+                                    .selected(
+                                        self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
+                                    )
+                                    .on_click(cx.listener(
+                                        |this, _, cx| {
+                                            this.toggle_search_option(
+                                                SearchOptions::CASE_SENSITIVE,
+                                                cx,
+                                            );
+                                        },
+                                    )),
+                                )
+                                .child(
+                                    IconButton::new("project-search-whole-word", Icon::WholeWord)
+                                        .tooltip(|cx| {
+                                            Tooltip::for_action(
+                                                "Toggle whole word",
+                                                &ToggleWholeWord,
+                                                cx,
+                                            )
+                                        })
+                                        .selected(
+                                            self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
+                                        )
+                                        .on_click(cx.listener(|this, _, cx| {
+                                            this.toggle_search_option(
+                                                SearchOptions::WHOLE_WORD,
+                                                cx,
+                                            );
+                                        })),
+                                )
+                            }),
                     )
                     .border_2()
                     .bg(white())
@@ -1630,7 +1530,22 @@ impl Render for ProjectSearchBar {
                                 .flex_1()
                                 .border_1()
                                 .mr_2()
-                                .child(search.included_files_editor.clone()),
+                                .child(search.included_files_editor.clone())
+                                .when(search.current_mode != SearchMode::Semantic, |this| {
+                                    this.child(
+                                        SearchOptions::INCLUDE_IGNORED.as_button(
+                                            search
+                                                .search_options
+                                                .contains(SearchOptions::INCLUDE_IGNORED),
+                                            cx.listener(|this, _, cx| {
+                                                this.toggle_search_option(
+                                                    SearchOptions::INCLUDE_IGNORED,
+                                                    cx,
+                                                );
+                                            }),
+                                        ),
+                                    )
+                                }),
                         )
                         .child(
                             h_stack()
@@ -1668,7 +1583,23 @@ impl Render for ProjectSearchBar {
                                         cx,
                                     )
                                 }),
-                        ),
+                        )
+                        .when(semantic_is_available, |this| {
+                            this.child(
+                                Button::new("project-search-semantic-button", "Semantic")
+                                    .selected(search.current_mode == SearchMode::Semantic)
+                                    .on_click(cx.listener(|this, _, cx| {
+                                        this.activate_search_mode(SearchMode::Semantic, cx)
+                                    }))
+                                    .tooltip(|cx| {
+                                        Tooltip::for_action(
+                                            "Toggle semantic search",
+                                            &ActivateSemanticMode,
+                                            cx,
+                                        )
+                                    }),
+                            )
+                        }),
                 )
                 .child(
                     IconButton::new("project-search-toggle-replace", Icon::Replace)
@@ -1755,34 +1686,14 @@ impl Render for ProjectSearchBar {
             .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
                 this.toggle_filters(cx);
             }))
-            .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
-                this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
-            }))
-            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
-                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
-            }))
-            .on_action(cx.listener(|this, action, cx| {
-                this.toggle_replace(action, cx);
-            }))
             .on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
                 this.activate_search_mode(SearchMode::Text, cx)
             }))
             .on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
                 this.activate_search_mode(SearchMode::Regex, cx)
             }))
-            .on_action(cx.listener(|this, action, cx| {
-                if let Some(search) = this.active_project_search.as_ref() {
-                    search.update(cx, |this, cx| {
-                        this.replace_next(action, cx);
-                    })
-                }
-            }))
-            .on_action(cx.listener(|this, action, cx| {
-                if let Some(search) = this.active_project_search.as_ref() {
-                    search.update(cx, |this, cx| {
-                        this.replace_all(action, cx);
-                    })
-                }
+            .on_action(cx.listener(|this, _: &ActivateSemanticMode, cx| {
+                this.activate_search_mode(SearchMode::Semantic, cx)
             }))
             .on_action(cx.listener(|this, action, cx| {
                 this.tab(action, cx);
@@ -1793,6 +1704,36 @@ impl Render for ProjectSearchBar {
             .on_action(cx.listener(|this, action, cx| {
                 this.cycle_mode(action, cx);
             }))
+            .when(search.current_mode != SearchMode::Semantic, |this| {
+                this.on_action(cx.listener(|this, action, cx| {
+                    this.toggle_replace(action, cx);
+                }))
+                .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
+                    this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
+                }))
+                .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
+                    this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
+                }))
+                .on_action(cx.listener(|this, action, cx| {
+                    if let Some(search) = this.active_project_search.as_ref() {
+                        search.update(cx, |this, cx| {
+                            this.replace_next(action, cx);
+                        })
+                    }
+                }))
+                .on_action(cx.listener(|this, action, cx| {
+                    if let Some(search) = this.active_project_search.as_ref() {
+                        search.update(cx, |this, cx| {
+                            this.replace_all(action, cx);
+                        })
+                    }
+                }))
+                .when(search.filters_enabled, |this| {
+                    this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
+                        this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
+                    }))
+                })
+            })
             .child(query_column)
             .child(mode_column)
             .child(replace_column)

crates/search2/src/search.rs 🔗

@@ -28,6 +28,7 @@ actions!(
         CycleMode,
         ToggleWholeWord,
         ToggleCaseSensitive,
+        ToggleIncludeIgnored,
         ToggleReplace,
         SelectNextMatch,
         SelectPrevMatch,
@@ -57,6 +58,7 @@ impl SearchOptions {
         match *self {
             SearchOptions::WHOLE_WORD => "Match Whole Word",
             SearchOptions::CASE_SENSITIVE => "Match Case",
+            SearchOptions::INCLUDE_IGNORED => "Include ignored",
             _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
@@ -65,6 +67,7 @@ impl SearchOptions {
         match *self {
             SearchOptions::WHOLE_WORD => ui::Icon::WholeWord,
             SearchOptions::CASE_SENSITIVE => ui::Icon::CaseSensitive,
+            SearchOptions::INCLUDE_IGNORED => ui::Icon::FileGit,
             _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
@@ -73,6 +76,7 @@ impl SearchOptions {
         match *self {
             SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord),
             SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive),
+            SearchOptions::INCLUDE_IGNORED => Box::new(ToggleIncludeIgnored),
             _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
@@ -85,6 +89,7 @@ impl SearchOptions {
         let mut options = SearchOptions::NONE;
         options.set(SearchOptions::WHOLE_WORD, query.whole_word());
         options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive());
+        options.set(SearchOptions::INCLUDE_IGNORED, query.include_ignored());
         options
     }