diff --git a/src/ui/app.rs b/src/ui/app.rs index bd3e4d94..49db39e6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1370,7 +1370,6 @@ impl Tty7App { }; let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx); let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx); - let panel_search = crate::ui::panel_search::PanelSearchState::new(window, cx); let editor = crate::ui::code_editor::EditorPanelState::new(window, cx); let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1")); let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080")); @@ -1383,7 +1382,8 @@ impl Tty7App { let right_panel_width = cx.global::().right_panel_width; let document_ratio = cx.global::().document_ratio; let right_panel_visible = cx.global::().right_panel_visible; - let right_panel_tab = cx.global::().right_panel_tab; + let right_panel_tab = + crate::ui::right_panel::shown_tab(cx.global::().right_panel_tab); let scm_graph_expanded = cx.global::().scm_graph_expanded; let sidebar_collapsed = cx.global::().sidebar_collapsed; let config_watch = cx.observe_global_in::(window, |this, window, cx| { @@ -1486,6 +1486,10 @@ impl Tty7App { cx.notify(); } }); + // The Files tab's one field drives both of its searches: names here, + // and what the files say through the content search. + let panel_search = + crate::ui::panel_search::PanelSearchState::new(file_search.clone(), window, cx); let mut app = Self { tabs, active, @@ -6927,9 +6931,6 @@ impl Tty7App { self.file_search.update(cx, |state, cx| { state.set_placeholder(t(L10nKey::SearchFiles), window, cx) }); - self.panel_search.input.update(cx, |state, cx| { - state.set_placeholder(t(L10nKey::PanelSearchPlaceholder), window, cx) - }); // The remote Files panel is built once with the app, so its placeholder // is the one input that would otherwise keep the old language. self.sftp_panel.filter_input.update(cx, |state, cx| { diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 2950e403..0595b259 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1677,11 +1677,28 @@ impl Tty7App { // A search that found nothing, and a tab with no directory behind it, // both used to render as an empty column that looks identical to a // tree still loading. - let blank = rows.is_empty().then(|| { - let text = match self.file_tree_searching(cx) { - true => t_fmt(L10nKey::SettingsNothingMatches, &[("query", &query)]), - false => t(L10nKey::OpenFileFromTree).to_string(), - }; + // Searching, the column is v5's two sections: the names that match, + // then what the files say. Either can be empty; the contents section + // speaks for itself, so a names section with nothing in it just + // steps aside. + let searching = self.file_tree_searching(cx); + let names_heading = (searching && !rows.is_empty()).then(|| { + let found = rows.iter().filter(|r| r.note.is_none()).count(); + search_section_heading( + t(L10nKey::PanelFilesNameMatches), + Some(found.to_string()), + cx, + ) + }); + // Asked for either way: with the field emptied, this is what lets the + // content search drop its last answer. + let contents = self.panel_search_section(window, cx); + let contents = match searching { + true => contents, + false => Vec::new(), + }; + let blank = (rows.is_empty() && !searching).then(|| { + let text = t(L10nKey::OpenFileFromTree).to_string(); div() .px(px(ROW_INSET)) .py_4() @@ -1702,7 +1719,13 @@ impl Tty7App { this.file_tree_key_down(ev, window, cx); })) .children(blank) + .children(names_heading) .children(self.render_tree_children(&rows, &decor, window, cx)) + .when(!contents.is_empty(), |column| { + column + .child(div().flex_none().h(px(SEARCH_SECTION_GAP))) + .children(contents) + }) // Everything the rows do not cover — the gap below the last one, // and the whole column while the tree is still empty — belongs to // the top of the tree. A row under the cursor wins: gpui hands a @@ -2451,6 +2474,38 @@ fn event_can_change_a_row(path: &Path, show_hidden: bool) -> bool { .is_some_and(|n| n.to_string_lossy().starts_with('.')) } +/// Between the Files tab's two search sections. +const SEARCH_SECTION_GAP: f32 = 10.; + +/// A heading over one of the Files tab's search sections — "File names", +/// "In file contents" — with its tally at the trailing edge, in caption ink. +pub(crate) fn search_section_heading(label: &str, tally: Option, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + h_flex() + .flex_none() + .h(px(22.)) + .px(px(ROW_INSET)) + .items_center() + .gap(px(8.)) + .text_size(gpui::rems(crate::ui::right_panel::HEADING)) + .text_color(muted) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_weight(gpui::FontWeight::MEDIUM) + .child(label.to_string()), + ) + .children(tally.map(|n| { + div() + .flex_none() + .font_features(crate::ui::theme::tabular_figures()) + .child(n) + })) + .into_any_element() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 857f55ad..4fb99957 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1556,6 +1556,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SwitcherTabToCrossColumns => "Tab to cross columns", L10nKey::SwitcherHintNavigate => "Navigate", L10nKey::SwitcherHintOpen => "Open", + L10nKey::SearchHintNextScope => "Next scope", + L10nKey::PanelFilesNameMatches => "File names", + L10nKey::PanelSearchInContents => "In file contents", L10nKey::SwitcherHintNewWindow => "New window", L10nKey::SwitcherLocalHost => "local", L10nKey::SwitcherConnectingTo => "Connecting to {machine}…", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4685903d..7871511c 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1601,6 +1601,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherTabToCrossColumns => "Tab で列を移動", L10nKey::SwitcherHintNavigate => "移動", L10nKey::SwitcherHintOpen => "開く", + L10nKey::SearchHintNextScope => "次のスコープ", + L10nKey::PanelFilesNameMatches => "ファイル名", + L10nKey::PanelSearchInContents => "ファイルの内容", L10nKey::SwitcherHintNewWindow => "新しいウィンドウ", L10nKey::SwitcherLocalHost => "ローカル", L10nKey::SwitcherConnectingTo => "{machine} に接続中…", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 0e76ed64..ea4ba80f 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1312,6 +1312,9 @@ l10n_keys! { SwitcherTabToCrossColumns, SwitcherHintNavigate, SwitcherHintOpen, + SearchHintNextScope, + PanelSearchInContents, + PanelFilesNameMatches, SwitcherHintNewWindow, SwitcherLocalHost, SwitcherConnectingTo, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 1e6cdff1..f54be00c 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1437,6 +1437,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherTabToCrossColumns => "按 Tab 换到另一列", L10nKey::SwitcherHintNavigate => "导航", L10nKey::SwitcherHintOpen => "打开", + L10nKey::SearchHintNextScope => "下一个范围", + L10nKey::PanelFilesNameMatches => "文件名", + L10nKey::PanelSearchInContents => "文件内容", L10nKey::SwitcherHintNewWindow => "新窗口", L10nKey::SwitcherLocalHost => "本机", L10nKey::SwitcherConnectingTo => "正在连接 {machine}…", diff --git a/src/ui/panel_search.rs b/src/ui/panel_search.rs index ca491bb7..624c8c0d 100644 --- a/src/ui/panel_search.rs +++ b/src/ui/panel_search.rs @@ -1,5 +1,6 @@ -//! The right panel's Search tab: find in files across the active tab's -//! project, on whichever machine that project lives. +//! Find in files for the right panel's Files tab: the "In file contents" +//! half of its search, across the active tab's project, on whichever machine +//! that project lives. //! //! The roots are the Files tab's own ([`Tty7App::project_roots`]), and the //! search runs through [`Host::search_content`](tty7_core::host::Host), so a @@ -16,8 +17,8 @@ use std::time::Duration; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, HighlightStyle, MouseButton, ScrollHandle, SharedString, - StyledText, Subscription, Window, div, px, rems, + AnyElement, App, Context, Entity, HighlightStyle, MouseButton, SharedString, StyledText, + Subscription, Window, div, px, rems, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{InputEvent, InputState}; @@ -26,12 +27,11 @@ use gpui_component::{ }; use tty7_core::host::{ContentHit, ContentLimits, ContentQuery}; -use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::app::Tty7App; use crate::ui::host_ops::HostOps; use crate::ui::host_registry::HostRegistry; -use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::right_panel::{META, ROW_FILL_RADIUS, ROW_GLYPH, ROW_INSET, TEXT_MONO}; -use crate::ui::scrollbar::with_vertical_scrollbar; use model::{FileGroup, Outcome, SearchKey, SearchRun}; @@ -39,9 +39,6 @@ use model::{FileGroup, Outcome, SearchKey, SearchRun}; /// than the name search's: every keystroke that does get through reads files. const DEBOUNCE: Duration = Duration::from_millis(250); -/// The pause between the search well and the results — the Files tab's. -const SEARCH_GAP: f32 = 10.; - const FILE_ROW_H: f32 = 26.; const HIT_ROW_H: f32 = 22.; const CHEVRON_W: f32 = 10.; @@ -51,8 +48,6 @@ const ROW_GAP: f32 = 6.; /// the icon. const HIT_LEAD: f32 = ROW_INSET + CHEVRON_W + ROW_GAP + ROW_GLYPH + ROW_GAP; -const PAD_BOTTOM: f32 = 16.; - pub(crate) struct PanelSearchState { pub(crate) input: Entity, pub(crate) case_sensitive: bool, @@ -65,18 +60,21 @@ pub(crate) struct PanelSearchState { /// The field takes focus on the next frame — set whenever the tab is /// brought forward, which happens where there is no `Window` to focus with. pub(crate) focus_pending: bool, - scroll: ScrollHandle, _input_sub: Subscription, } impl PanelSearchState { - pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { - let input = cx - .new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::PanelSearchPlaceholder))); - let sub = cx.subscribe_in(&input, window, |app, _input, ev, _window, cx| match ev { - InputEvent::Change => cx.notify(), - InputEvent::PressEnter { .. } => app.panel_search_refresh(cx), - _ => {} + /// The search over `input` — the Files tab's field, which it shares + /// with the name search there. + pub(crate) fn new( + input: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let sub = cx.subscribe_in(&input, window, |app, _input, ev, _window, cx| { + if let InputEvent::PressEnter { .. } = ev { + app.panel_search_refresh(cx) + } }); PanelSearchState { input, @@ -86,7 +84,6 @@ impl PanelSearchState { run: SearchRun::default(), collapsed: HashSet::new(), focus_pending: false, - scroll: ScrollHandle::new(), _input_sub: sub, } } @@ -273,31 +270,41 @@ impl Tty7App { .is_some_and(|remote| remote.kind == RemoteKind::NativeSsh) } - pub(crate) fn render_panel_search( + /// The Files tab's "In file contents" section, for what its field holds: + /// a heading with the tally, then either the hits grouped by file or the + /// one line that says why there are none. Empty while nothing is typed — + /// the tree is showing then. + pub(crate) fn panel_search_section( &mut self, window: &mut Window, cx: &mut Context, - ) -> AnyElement { - if std::mem::take(&mut self.panel_search.focus_pending) { - self.panel_search - .input - .update(cx, |input, cx| input.focus(window, cx)); - } - let title = self.panel_title(t(L10nKey::PanelSearchTitle), None, None, window, cx); - let toggles = self.panel_search_toggles(cx); - let search = self.panel_search_with(&self.panel_search.input.clone(), Some(toggles), cx); + ) -> Vec { let body = self.panel_search_body(window, cx); - let content = self.panel_search_content(body, cx); - v_flex() - .flex_1() - .min_h_0() - .child(title) - .child(div().flex_none().pb(px(SEARCH_GAP)).child(search)) - .child(content) - .into_any_element() + if matches!(body, Body::Idle(_)) { + return Vec::new(); + } + let tally = self.panel_search_tally(&body); + let mut rows = vec![crate::ui::file_tree::search_section_heading( + t(L10nKey::PanelSearchInContents), + tally, + cx, + )]; + rows.extend(self.panel_search_rows(body, cx)); + rows } - fn panel_search_toggles(&self, cx: &mut Context) -> AnyElement { + /// The heading's trailing count: hits, once there are some to count. + fn panel_search_tally(&self, body: &Body) -> Option { + let landed = self.panel_search.run.landed()?; + match (body, &landed.outcome) { + (Body::Results, Outcome::Found(_)) => { + Some(model::hit_count(&landed.groups).to_string()) + } + _ => None, + } + } + + pub(crate) fn panel_search_toggles(&self, cx: &mut Context) -> AnyElement { let state = &self.panel_search; let muted = cx.theme().muted_foreground; let toggle = |id: &'static str, @@ -346,7 +353,7 @@ impl Tty7App { .into_any_element() } - fn panel_search_content(&mut self, body: Body, cx: &mut Context) -> AnyElement { + fn panel_search_rows(&mut self, body: Body, cx: &mut Context) -> Vec { let danger = cx.theme().danger; let note = |text: String, hint: Option, ink: Option, cx: &App| { let muted = cx.theme().muted_foreground; @@ -415,16 +422,7 @@ impl Tty7App { )), Body::Results => rows.extend(self.panel_search_results(cx)), } - let column = v_flex() - .id("panel-search-results") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .track_scroll(&self.panel_search.scroll) - .px(px(CONTENT_INSET)) - .pb(px(PAD_BOTTOM)) - .children(rows); - with_vertical_scrollbar("panel-search-scrollbar", column, &self.panel_search.scroll) + rows } fn panel_search_results(&self, cx: &mut Context) -> Vec { @@ -435,33 +433,21 @@ impl Tty7App { return Vec::new(); }; let muted = cx.theme().muted_foreground; - let hits = model::hit_count(&landed.groups); - let summary = match self.panel_search.run.running() { - true => t(L10nKey::PanelSearchSearching).to_string(), - false => t_fmt( - L10nKey::PanelSearchSummary, - &[ - ( - "results", - &t_plural(L10nKey::PanelSearchResultCount, hits, &[]), - ), - ( - "files", - &t_plural(L10nKey::PanelSearchFileCount, landed.groups.len(), &[]), - ), - ], - ), - }; - let mut rows: Vec = vec![ - div() - .flex_none() - .px(px(ROW_INSET)) - .pb(px(4.)) - .text_size(rems(META)) - .text_color(muted) - .child(summary) - .into_any_element(), - ]; + // The heading above carries the tally; the only thing left to say + // here is that a newer answer is on its way. + let mut rows: Vec = Vec::new(); + if self.panel_search.run.running() { + rows.push( + div() + .flex_none() + .px(px(ROW_INSET)) + .pb(px(4.)) + .text_size(rems(META)) + .text_color(muted) + .child(t(L10nKey::PanelSearchSearching)) + .into_any_element(), + ); + } for (index, group) in landed.groups.iter().enumerate() { let collapsed = self.panel_search.collapsed.contains(&group.path); rows.push(self.panel_search_file_row(index, group, collapsed, cx)); diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 1cef4940..7eccaf45 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -15,6 +15,16 @@ use crate::ui::app::{ use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::scrollbar::with_vertical_scrollbar; +/// The tab a stored choice shows. Search was a tab of its own until the +/// Files tab took over searching file contents; a config that still says +/// `"search"` opens on Files. +pub(crate) fn shown_tab(tab: RightPanelTab) -> RightPanelTab { + match tab { + RightPanelTab::Search => RightPanelTab::Files, + tab => tab, + } +} + /// Wide enough for the three word tabs, bare, beside the two chrome tiles at /// the default interface size — the Changes count is dropped before a label is /// ever cut. A larger UI font raises the floor past this; see @@ -517,11 +527,13 @@ impl Tty7App { } pub(crate) fn set_right_panel_tab(&mut self, tab: RightPanelTab, cx: &mut Context) { - // Every way to the Search tab ends here, and none of them has a - // `Window` to move focus with; the tab takes it on its next frame. + // Every way to find in files ends here, and none of them has a + // `Window` to move focus with; the Files field takes it on its next + // frame. if tab == RightPanelTab::Search { self.panel_search.focus_pending = true; } + let tab = shown_tab(tab); self.right_panel_tab = tab; self.right_panel_visible = true; self.update_config(cx, |cfg| { @@ -552,8 +564,7 @@ impl Tty7App { let body = match tab { RightPanelTab::Info => self.render_panel_info(window, cx), RightPanelTab::Scm => self.render_panel_scm(window, cx), - RightPanelTab::Files => self.render_panel_files(window, cx), - RightPanelTab::Search => self.render_panel_search(window, cx), + RightPanelTab::Files | RightPanelTab::Search => self.render_panel_files(window, cx), RightPanelTab::GitHub => self.render_panel_github(window, cx), }; let (backing, handle) = self.right_panel_resize(window, cx); @@ -1989,8 +2000,15 @@ impl Tty7App { return self.render_panel_sftp(host.unwrap_or_default(), window, cx); } + if std::mem::take(&mut self.panel_search.focus_pending) { + self.file_search + .update(cx, |input, cx| input.focus(window, cx)); + } let title = self.panel_title(t(L10nKey::PanelFilesTitle), None, None, window, cx); - let search = self.panel_search(&self.file_search.clone(), cx); + // One field for names and text alike. The switches only shape the + // text search; names are always matched loosely. + let toggles = self.panel_search_toggles(cx); + let search = self.panel_search_with(&self.file_search.clone(), Some(toggles), cx); let rows = self.render_file_tree_rows(window, cx); v_flex() .flex_1() diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs index 13508e1e..19dbaeb5 100644 --- a/src/ui/search/mod.rs +++ b/src/ui/search/mod.rs @@ -1,6 +1,6 @@ -//! Search Everywhere: one modal over everything the app can find — files, -//! actions, terminals, hosts — each in a tab of its own, and all of them at -//! once in the All tab. +//! Search Everywhere: one modal over everything the app can find — terminals, +//! sessions, hosts, actions — each in a tab of its own, and all of them at +//! once in the All tab. Files are Go to File's alone (see [`SearchTab::ORDER`]). //! //! - [`command`]: what a row runs ([`CommandKind`]) and the rows themselves. //! - [`sources`]: what each tab holds and how it ranks against a query. @@ -35,15 +35,18 @@ pub(crate) enum SearchTab { impl SearchTab { /// The tab row, left to right, and the order Tab walks it. /// - /// Files right after All: it is the tab its own chord opens, and what - /// VS Code's ⌘P taught people to expect first. - pub(crate) const ORDER: [SearchTab; 6] = [ + /// Files is not in it. Go to File (its own chord) still opens the search + /// on it, but the row, Tab and the All tab leave it out: the right + /// panel's Files tab is where the project is searched, names and text + /// together, and a sample of paths among tabs and hosts answered nothing + /// anyone had typed. Commands last, the way the rest of the row goes + /// from the things you have to the things you can do. + pub(crate) const ORDER: [SearchTab; 5] = [ SearchTab::All, - SearchTab::Files, - SearchTab::Actions, SearchTab::Terminals, SearchTab::Sessions, SearchTab::Hosts, + SearchTab::Actions, ]; pub(crate) fn title(self) -> &'static str { @@ -86,11 +89,18 @@ mod tests { #[test] fn tab_steps_wrap_both_ways() { - assert_eq!(SearchTab::All.step(true), SearchTab::Files); - assert_eq!(SearchTab::Files.step(true), SearchTab::Actions); - assert_eq!(SearchTab::Hosts.step(true), SearchTab::All); - assert_eq!(SearchTab::All.step(false), SearchTab::Hosts); - assert_eq!(SearchTab::Terminals.step(false), SearchTab::Actions); + assert_eq!(SearchTab::All.step(true), SearchTab::Terminals); + assert_eq!(SearchTab::Hosts.step(true), SearchTab::Actions); + assert_eq!(SearchTab::Actions.step(true), SearchTab::All); + assert_eq!(SearchTab::All.step(false), SearchTab::Actions); + assert_eq!(SearchTab::Terminals.step(false), SearchTab::All); assert_eq!(SearchTab::Terminals.step(true), SearchTab::Sessions); } + + #[test] + fn files_is_reached_by_its_chord_not_by_the_row() { + assert!(!SearchTab::ORDER.contains(&SearchTab::Files)); + // Tab out of Go to File lands on the row again. + assert_eq!(SearchTab::Files.step(true), SearchTab::Terminals); + } } diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs index cb2fb747..35404278 100644 --- a/src/ui/search/sources.rs +++ b/src/ui/search/sources.rs @@ -728,30 +728,26 @@ mod tests { catalog } - /// Files join the All tab once something is typed, under their own - /// header, and stay out of it before — a sample of the project is not an - /// answer to anything. + /// Files stay off the All tab, typed or not: the right panel's Files tab + /// searches the project, and Go to File has the search to itself. #[gpui::test] - fn files_are_on_the_all_tab_only_once_a_query_finds_them(cx: &mut TestAppContext) { + fn files_are_only_on_their_own_tab(cx: &mut TestAppContext) { with_config(cx); let catalog = with_files(&["src/main.rs", "src/ui/app.rs"]); cx.update(|cx| { - // No dot in it: `main.rs` also reads as a host name, and a typed - // address leads the All tab whatever else matches. - let sections = catalog.sections(SearchTab::All, "ui/app", cx); - assert_eq!(sections[0].title.as_deref(), Some("Files")); - assert_eq!(row_titles(§ions[0]), vec!["app.rs"]); + for query in ["", "ui/app"] { + let sections = catalog.sections(SearchTab::All, query, cx); + let headers: Vec<_> = sections.iter().filter_map(|s| s.title.clone()).collect(); + assert!( + !headers.iter().any(|h| h == "Files"), + "no files on the All tab for {query:?}: {headers:?}" + ); + } - let empty = catalog.sections(SearchTab::All, "", cx); - let headers: Vec<_> = empty.iter().filter_map(|s| s.title.clone()).collect(); - assert!( - !headers.iter().any(|h| h == "Files"), - "no files before a query: {headers:?}" - ); - - // The tab itself shows some before anything is typed. let own = catalog.sections(SearchTab::Files, "", cx); assert_eq!(own[0].rows.len(), 2); + let found = catalog.sections(SearchTab::Files, "ui/app", cx); + assert_eq!(row_titles(&found[0]), vec!["app.rs"]); }); } diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs index 25c97665..aa7f391e 100644 --- a/src/ui/search/view.rs +++ b/src/ui/search/view.rs @@ -92,9 +92,10 @@ impl SearchDelegate { (t.foreground, t.muted_foreground) }; - // The title holds its width longest; the subtitle beside it is what - // truncates first. - let mut left = h_flex().flex_1().min_w_0().items_center().gap(px(8.)); + // The title holds its width longest, with what was typed picked out + // in it; the subtitle goes to the right edge, in caption ink, and is + // what truncates first. + let mut left = h_flex().flex_1().min_w_0().items_center().gap(px(10.)); if let Some(avatar) = item.avatar { left = left.child(crate::ui::tab_strip::avatar( ("search-avatar", ix.section * 1000 + ix.row), @@ -103,23 +104,39 @@ impl SearchDelegate { cx, )); } + let title = gpui::SharedString::from(item.title.clone()); + let title = match match_range(&item.title, &self.query) { + Some(range) => gpui::StyledText::new(title).with_highlights([( + range, + gpui::HighlightStyle { + font_weight: Some(FontWeight::SEMIBOLD), + color: Some(fg), + ..Default::default() + }, + )]), + None => gpui::StyledText::new(title), + }; left = left.child( div() - .flex_shrink_0() - .max_w_full() + .flex_shrink(1.) + .min_w(px(40.)) .truncate() - .text_color(fg) - .when(picked, |d| d.font_weight(FontWeight::MEDIUM)) - .child(item.title.clone()), + .text_color(match self.query.trim().is_empty() { + true => fg, + false => fg.opacity(0.78), + }) + .child(title), ); if let Some(subtitle) = item.subtitle.clone() { left = left.child( - div() - .min_w_0() - .truncate() - .text_size(rems(ROW_META)) - .text_color(muted) - .child(subtitle), + div().flex_1().min_w(px(24.)).flex().justify_end().child( + div() + .min_w_0() + .truncate() + .text_size(rems(ROW_META)) + .text_color(muted) + .child(subtitle), + ), ); } @@ -675,35 +692,42 @@ impl SearchView { } } + /// The scope row, under the search field: plain words, the one showing + /// on the popover's selected step. Go to File has no pill of its own — + /// it is a chord, not a scope — so there none is lit. fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { - // The tabs take the rows' neutral steps: the one showing is where the - // list is, not an action, so it is the selected step and not an accent. let sf = cx.global::().popover; let (active_bg, hover_bg) = (gpui::rgb(sf.selected), gpui::rgb(sf.hover)); let theme = cx.theme(); let (fg, muted) = (theme.foreground, theme.muted_foreground); h_flex() - .px(px(LIST_PAD)) - .pt(px(8.)) + .h(px(TABS_H)) + .px(px(SCOPE_PAD)) + .pb(px(6.)) .gap(px(2.)) .items_center() + .bg(theme.popover) + .border_b_1() + .border_color(theme.border) .children(SearchTab::ORDER.into_iter().enumerate().map(|(i, tab)| { let active = tab == self.tab; div() .id(("search-tab", i)) .h(px(24.)) - .px(px(10.)) + .px(px(9.)) .flex() .items_center() .rounded(px(6.)) - .text_size(rems(ROW_META)) + .text_size(rems(SCOPE_TEXT)) .cursor_pointer() .map(|d| match active { true => d .bg(active_bg) .text_color(fg) .font_weight(FontWeight::MEDIUM), - false => d.text_color(muted).hover(move |d| d.bg(hover_bg)), + false => d + .text_color(muted) + .hover(move |d| d.bg(hover_bg).text_color(fg)), }) .child(tab.title()) .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { @@ -711,8 +735,6 @@ impl SearchView { this.list.update(cx, |state, cx| state.focus(window, cx)); })) })) - .child(div().flex_1()) - .child(keycap(crate::ui::keymap::key_tokens("tab").join(""), cx)) } /// The switcher's footer, minus its New workspace button: the keys that @@ -726,18 +748,25 @@ impl SearchView { .children(keys) .child(label) }; + let tabs = !self.in_sub_list(); h_flex() .flex_none() .items_center() - .justify_end() .gap(px(16.)) .h(px(FOOTER_H)) - .px(px(14.)) + .px(px(16.)) .border_t_1() .border_color(theme.border) .overflow_hidden() .text_size(rems(ROW_META)) .text_color(theme.muted_foreground) + .when(tabs, |row| { + row.child(hint( + vec![keycap(crate::ui::keymap::key_tokens("tab").join(""), cx)], + t(L10nKey::SearchHintNextScope), + )) + }) + .child(div().flex_1()) .child(hint( vec![keycap("↑", cx), keycap("↓", cx)], t(L10nKey::SwitcherHintNavigate), @@ -759,8 +788,8 @@ impl EventEmitter for SearchView {} /// footer gives its own. const ROW_H: f32 = 32.; -/// A section heading: the right panel's 28px heading row. -const HEADER_H: f32 = 28.; +/// A section heading: a compact caption over its rows. +const HEADER_H: f32 = 24.; /// How far the list sits in from the card's edges, and how far a row's text /// then sits in from its own fill — the switcher's `COLUMN_PAD` and @@ -768,18 +797,22 @@ const HEADER_H: f32 = 28.; const LIST_PAD: f32 = 8.; const ROW_PAD: f32 = 10.; +/// The scope row's inset, and its words: a step under the rows. +const SCOPE_PAD: f32 = 12.; +const SCOPE_TEXT: f32 = 12. / 16.; + /// Where a row's label starts, so a section heading lands on the same column /// as the rows under it. A heading has neither the row's inset nor its /// padding, so it carries the sum. const LABEL_INSET: f32 = LIST_PAD + ROW_PAD; -/// The row fill's corner — the switcher's `LIST_RADIUS`. -const ROW_RADIUS: f32 = 8.; +/// The row fill's corner. +const ROW_RADIUS: f32 = 7.; /// The card's breathing room from the window's bottom edge, and its width. /// Its corner and its footer are `ui::dialog`'s, the switcher's numbers. const CARD_MARGIN: f32 = 24.; -const CARD_MAX_W: f32 = 600.; +const CARD_MAX_W: f32 = 620.; /// The search row gpui-component's `List` draws above the rows: a 32px field /// with 6px above and below and a 1px rule. @@ -792,8 +825,34 @@ const ROW_META: f32 = 11.5 / 16.; const AVATAR: f32 = 18.; -/// The tab row's height with its padding, reserved out of the list's. -const TABS_H: f32 = 32.; +/// The scope row's height with its padding and rule, reserved out of the +/// list's. +const TABS_H: f32 = 34.; + +/// Where `query` first appears in `title`, ignoring case: the stretch of the +/// title the row picks out. `None` for an empty query, or one the fuzzy +/// scorer matched as scattered letters — picking out stray letters reads as +/// noise, not as an answer. +fn match_range(title: &str, query: &str) -> Option> { + let query = query.trim(); + if query.is_empty() { + return None; + } + let lower = |c: char| c.to_lowercase().next().unwrap_or(c); + let needle: Vec = query.chars().map(lower).collect(); + let hay: Vec<(usize, char)> = title.char_indices().collect(); + (0..hay.len()).find_map(|i| { + let window = hay.get(i..i + needle.len())?; + window + .iter() + .zip(&needle) + .all(|((_, c), n)| lower(*c) == *n) + .then(|| { + let end = hay.get(i + needle.len()).map_or(title.len(), |(b, _)| *b); + window[0].0..end + }) + }) +} /// The chord that opens the selected row for editing instead of running it. /// @@ -852,13 +911,28 @@ impl Render for SearchView { .map(|panel| crate::ui::theme::floating_surface(panel, cx)) .rounded(px(CARD_RADIUS)) .overflow_hidden() - .when(tabs, |card| card.child(self.render_tabs(cx))) .child( List::new(&self.list) .search_placeholder(placeholder) - .py(px(LIST_PAD)) - .max_h(list_max_h), + .pt(px(tabs_h + 6.)) + .pb(px(LIST_PAD)) + .max_h(list_max_h + px(tabs_h)), ) + // The scope row sits under the field, the way v5 draws it. The + // field is the list's own and the list gives nothing a slot + // between it and the rows, so the row is laid over the list's top + // padding instead — one pixel higher, over the field's rule, so + // the only rule is the one under the scopes. + .when(tabs, |card| { + card.child( + div() + .absolute() + .top(px(SEARCH_H - 1.)) + .left_0() + .right_0() + .child(self.render_tabs(cx)), + ) + }) // The switcher's `esc` cap in the search row's trailing corner. // Laid over the row rather than inside it — the row is the list's // own — and only while the field is empty, since typing brings up @@ -868,8 +942,8 @@ impl Render for SearchView { card.child( div() .absolute() - .top(px(tabs_h + (SEARCH_H - 1. - KEYCAP) / 2.)) - .right(px(14.)) + .top(px((SEARCH_H - 1. - KEYCAP) / 2.)) + .right(px(16.)) .child(keycap("esc", cx)), ) }) @@ -939,6 +1013,7 @@ impl RenderOnce for SearchRow { h_flex() .id(self.id) .items_center() + .flex_none() .h(px(ROW_H)) .mx(px(LIST_PAD)) .px(px(ROW_PAD)) @@ -982,8 +1057,8 @@ mod tests { vcx.run_until_parked(); let view = open(&app, &mut vcx); - // Past Files, which comes right after All. - vcx.simulate_keystrokes("tab tab"); + // Backwards round the end, to Commands, which comes last. + vcx.simulate_keystrokes("shift-tab"); vcx.run_until_parked(); view.read_with(&vcx, |view, cx| { assert_eq!(view.tab, SearchTab::Actions); @@ -991,10 +1066,10 @@ mod tests { }); assert_eq!(first_kind(&view, &mut vcx), Some(CommandKind::SplitRight)); - // Backwards, and round the end. - vcx.simulate_keystrokes("shift-tab shift-tab shift-tab"); + // Forwards, round the end again, and never through Files. + vcx.simulate_keystrokes("tab tab tab"); vcx.run_until_parked(); - view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Hosts)); + view.read_with(&vcx, |view, _| assert_eq!(view.tab, SearchTab::Sessions)); assert!( app.read_with(&vcx, |app, _| app.search.is_some()), "Tab stays inside the search instead of walking focus out of it" diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index ea76d693..881c1785 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -618,56 +618,52 @@ pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> But chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx) } -/// The right panel's tabs, left to right: which pane, what its tooltip calls -/// it, and the glyph the row draws for it. Info leads as the default and the -/// pane's overview; then two pairs — the project's files (Files, Search) and -/// its version control, local to remote (Changes, GitHub). GitHub is last so -/// that hiding it for a repository without a GitHub remote moves nothing. -const RIGHT_PANEL_TABS: [(RightPanelTab, L10nKey, &str); 5] = [ - ( - RightPanelTab::Info, - L10nKey::PanelInfoTitle, - "icons/info.svg", - ), - ( - RightPanelTab::Files, - L10nKey::PanelFilesTitle, - "icons/folder.svg", - ), - ( - RightPanelTab::Search, - L10nKey::PanelSearchTitle, - "icons/search.svg", - ), - ( - RightPanelTab::Scm, - L10nKey::PanelChangesTitle, - "icons/git-branch.svg", - ), - ( - RightPanelTab::GitHub, - L10nKey::PanelGitHubTitle, - "icons/github.svg", - ), +/// The right panel's tabs, left to right, by name. Info leads as the default +/// and the pane's overview; then the project's files, and its version +/// control, local to remote (Changes, GitHub). GitHub is last so that hiding +/// it for a repository without a GitHub remote moves nothing. Search is not +/// among them: the Files tab searches names and contents in one field. +const RIGHT_PANEL_TABS: [(RightPanelTab, L10nKey); 4] = [ + (RightPanelTab::Info, L10nKey::PanelInfoTitle), + (RightPanelTab::Files, L10nKey::PanelFilesTitle), + (RightPanelTab::Scm, L10nKey::PanelChangesTitle), + (RightPanelTab::GitHub, L10nKey::PanelGitHubTitle), ]; -/// The glyph each tab draws, in px. -const RIGHT_PANEL_TAB_ICON: f32 = 15.; +fn right_panel_tab_size(window: &Window) -> f32 { + window.rem_size().as_f32() * crate::ui::right_panel::TAB_TEXT +} -/// What the bare tab glyphs take, padding included. The panel's floor is +fn right_panel_tab_font(cx: &gpui::App) -> gpui::Font { + gpui::Font { + family: cx.theme().font_family.clone(), + features: Default::default(), + fallbacks: None, + // The current tab's weight, which is the widest any label is drawn at. + weight: FontWeight::MEDIUM, + style: Default::default(), + } +} + +/// What the bare tab labels take, padding included. The panel's floor is /// built on it, so the chrome tiles beside them always fit. -pub(crate) fn right_panel_tab_labels_w(_window: &Window, _cx: &gpui::App) -> f32 { - RIGHT_PANEL_TABS.len() as f32 * (RIGHT_PANEL_TAB_ICON + 2. * (TAB_OUTER_PAD + TAB_INNER_PAD)) +pub(crate) fn right_panel_tab_labels_w(window: &Window, cx: &gpui::App) -> f32 { + let size = right_panel_tab_size(window); + let font = right_panel_tab_font(cx); + RIGHT_PANEL_TABS + .iter() + .map(|(_, key)| { + measure_text(window.text_system(), &font, size, t(*key)) + + 2. * (TAB_OUTER_PAD + TAB_INNER_PAD) + }) + .sum() } /// Right panel tab geometry: each tab's click target reaches this far past /// its pill, so neighbouring pills sit twice this apart. pub(crate) const TAB_OUTER_PAD: f32 = 2.; -/// The selected tab's pill reaches this far past its glyph. -const TAB_INNER_PAD: f32 = 5.; -/// The pill's height and corner. -const TAB_PILL_H: f32 = 24.; -const TAB_PILL_RADIUS: f32 = 6.; +/// A label's hover target reaches this far past its word. +const TAB_INNER_PAD: f32 = 4.; /// How wide the two chrome tiles at the trailing end of the title bar are, with /// the padding around them. @@ -1311,27 +1307,21 @@ impl Tty7App { ) } - /// The right panel's tabs: a glyph each, named in a tooltip. - /// - /// No count on Changes: a number beside one glyph of five read as a badge - /// on that tab alone, and the Changes tab itself leads with the same count - /// under its own heading. + /// The right panel's tabs: words, the current one in body ink and medium + /// weight — v5's inspector row. Four names fit the panel's resting width + /// once Search folded into Files; this is secondary navigation, not an + /// action, so it gets neither a pill nor a bar. pub(crate) fn right_panel_tabs(&self, cx: &mut Context) -> Vec { let active_tab = self.right_panel_tab; let body_ink = cx.theme().foreground; - let selected_fill = cx.global::().sidebar.selected; + let muted = cx.theme().muted_foreground; RIGHT_PANEL_TABS .into_iter() - .map(|(tab, label_key, icon)| { + .map(|(tab, label_key)| { let current = active_tab == tab; - // Glyphs, not words: five names do not fit the panel's 280px - // resting width, so each tab is an icon and says its name in a - // tooltip. Ink alone could not carry "this one" between five - // glyphs of one weight, so the current tab also sits on the - // sidebar's selected fill. let ink = match current { true => body_ink, - false => cx.theme().muted_foreground, + false => muted, }; div() .id(("right-panel-tab", tab as usize)) @@ -1345,24 +1335,18 @@ impl Tty7App { .px(px(TAB_OUTER_PAD)) .cursor_pointer() .child( - h_flex() + div() .flex_shrink_0() - .h(px(TAB_PILL_H)) .px(px(TAB_INNER_PAD)) - .rounded(px(TAB_PILL_RADIUS)) - .when(current, |pill| pill.bg(gpui::rgb(selected_fill))) - .items_center() + .text_size(gpui::rems(crate::ui::right_panel::TAB_TEXT)) + .font_weight(match current { + true => FontWeight::MEDIUM, + false => FontWeight::NORMAL, + }) .text_color(ink) .hover(move |s| s.text_color(body_ink)) - .child( - gpui::svg() - .flex_shrink_0() - .path(icon) - .size(px(RIGHT_PANEL_TAB_ICON)) - .text_color(ink), - ), + .child(t(label_key)), ) - .tooltip(move |window, cx| Tooltip::new(t(label_key)).build(window, cx)) // Another tab switches to it; the current one puts the panel // away, the way an activity bar behaves everywhere else. // (These only exist while the panel is open, so