From d7eece6cf970b4a2a230d9c69c93cffed266a559 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:49:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20v5=20search=20=E2=80=94=20Search=20?= =?UTF-8?q?Everywhere=20without=20Files,=20one=20Files=20search=20for=20na?= =?UTF-8?q?mes=20and=20text,=20word=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search Everywhere follows the v5 design: the scope row sits under the field, Files leaves the row, Tab and the All tab (Go to File still opens it), the scopes run All, Terminals, Sessions, Hosts, Commands, matches are picked out in the title, subtitles move to the right edge, and the footer leads with Next scope. - The right panel's Files field searches file names and file contents at once, in two sections. The Search tab folds into it; a stored "search" tab and Show Search open Files with the field focused. - The right panel's tab row is words again: Info, Files, Changes, GitHub. Claude-Session: https://claude.ai/code/session_01Aeskbok7Ah7dvKWnaaJFEg --- src/ui/app.rs | 11 +-- src/ui/file_tree.rs | 65 ++++++++++++++-- src/ui/i18n/en.rs | 3 + src/ui/i18n/ja.rs | 3 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 3 + src/ui/panel_search.rs | 142 ++++++++++++++++------------------ src/ui/right_panel.rs | 28 +++++-- src/ui/search/mod.rs | 36 +++++---- src/ui/search/sources.rs | 30 ++++---- src/ui/search/view.rs | 159 ++++++++++++++++++++++++++++----------- src/ui/tab_strip.rs | 118 +++++++++++++---------------- 12 files changed, 369 insertions(+), 232 deletions(-) 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