diff --git a/crates/assets/assets/icons/grid.svg b/crates/assets/assets/icons/grid.svg new file mode 100644 index 0000000..ecf1466 --- /dev/null +++ b/crates/assets/assets/icons/grid.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/recent.svg b/crates/assets/assets/icons/recent.svg new file mode 100644 index 0000000..e7c76e6 --- /dev/null +++ b/crates/assets/assets/icons/recent.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/trending.svg b/crates/assets/assets/icons/trending.svg new file mode 100644 index 0000000..314cd1f --- /dev/null +++ b/crates/assets/assets/icons/trending.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index afd79b4..3fd3419 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -93,6 +93,9 @@ pub enum CustomIconName { Tag, Markdown, Share, + Trending, + Recent, + Grid, } impl IconNamed for CustomIconName { @@ -118,6 +121,9 @@ impl IconNamed for CustomIconName { CustomIconName::Tag => "icons/tag.svg", CustomIconName::Markdown => "icons/markdown.svg", CustomIconName::Share => "icons/share.svg", + CustomIconName::Trending => "icons/trending.svg", + CustomIconName::Recent => "icons/recent.svg", + CustomIconName::Grid => "icons/grid.svg", } .into() } diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 886eb95..e7bef21 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -807,7 +807,7 @@ fn tree_diff( /// Parse a `git format-patch` output (a single patch or a patch series) /// into the same [`CommitDiff`] structure used for commit diffs. /// -/// The mbox envelope (From/Subject/… headers, commit body and diffstat) +/// The mbox envelope (From/Subject/... headers, commit body and diffstat) /// is skipped; every `diff --git` section becomes one [`FileDiff`]. Paths /// are taken from the section headers, with git's C-style quoting undone. /// Sections without hunks (pure renames, mode changes, binary files) are @@ -895,7 +895,7 @@ fn name_from_address(from: &str) -> String { } } -/// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` … prefix from a patch +/// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` ... prefix from a patch /// `Subject:` header. fn strip_patch_prefix(subject: &str) -> String { let trimmed = subject.trim(); @@ -1060,7 +1060,7 @@ fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { } /// The kind of a hunk body line, from its first character; lines that don't -/// belong to the hunk (headers, `\ No newline…`, the next section) yield +/// belong to the hunk (headers, `\ No newline...`, the next section) yield /// `None`. fn line_prefix_kind(line: &str) -> Option { match line.as_bytes().first()? { diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 41af8c6..868f91d 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -134,7 +134,7 @@ impl RepoDetailView { div() .text_sm() .text_color(cx.theme().muted_foreground) - .child("Cloning repository…"), + .child("Cloning repository..."), ) .into_any_element() } else if let Some(error) = error { diff --git a/crates/workspace/src/views/repo_detail/issue_detail.rs b/crates/workspace/src/views/repo_detail/issue_detail.rs index 7eb0977..25ec880 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/repo_detail/issue_detail.rs @@ -37,7 +37,7 @@ impl IssueDetailView { cx: &mut Context, ) -> Self { let comment_input = - cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…")); + cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); Self { focus_handle: cx.focus_handle(), diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index fda3ec6..8e7d8c9 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -347,7 +347,7 @@ impl IssuesView { /// through [`RepoStore::open_issue`] when confirmed. pub(super) fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); - let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue…")); + let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue...")); window.open_dialog(cx, move |dialog, _window, _cx| { let subject = subject.clone(); diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index 31560fb..0cb57f2 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -94,7 +94,7 @@ impl PullRequestDetailView { ) -> Self { let tree_state = cx.new(|cx| TreeState::new(cx)); let comment_input = - cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…")); + cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); // Same display name as the repo detail panel's title. let repo_name = store diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index 02c29d4..acff79b 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -442,9 +442,9 @@ pub(super) fn open_new_pull_request_dialog( ) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); let description = - cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change…")); - let patch = - cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…")); + cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change...")); + let patch = cx + .new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output...")); window.open_dialog(cx, move |dialog, _window, _cx| { let subject = subject.clone(); diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index f6a2a1a..38112c7 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,5 +1,6 @@ use std::rc::Rc; +use assets::CustomIconName; use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ @@ -8,9 +9,11 @@ use gpui::{ }; use gpui_base::Button as BaseButton; use gpui_component::avatar::Avatar; +use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::scroll::Scrollbar; use gpui_component::{ - ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, + v_virtual_list, }; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; @@ -38,10 +41,24 @@ enum RepoFilter { } impl RepoFilter { - fn visible(self, store: &RepoListStore) -> Vec { + /// Indices into the store's `announcements` included by this filter, in + /// display order, narrowed to repositories whose name (or id) contains + /// `query`; an empty query matches everything. + fn visible(self, store: &RepoListStore, query: &str) -> Vec { let announcements = &store.announcements; let mut indices: Vec = (0..announcements.len()).collect(); + // Narrow by the search query first, so "Recent" limits the matches + // and "Popular" ranks them. + let query = query.trim().to_lowercase(); + if !query.is_empty() { + indices.retain(|&ix| { + let announcement = &announcements[ix]; + let name = announcement.name.as_deref().unwrap_or(&announcement.id); + name.to_lowercase().contains(&query) + }); + } + match self { Self::All => {} Self::Recent => indices.truncate(RECENT_COUNT), @@ -57,6 +74,14 @@ impl RepoFilter { indices } + + fn icon_name(self) -> CustomIconName { + match self { + Self::All => CustomIconName::Grid, + Self::Recent => CustomIconName::Recent, + Self::Popular => CustomIconName::Trending, + } + } } /// Browse all announced repositories. @@ -72,22 +97,35 @@ pub struct RepoListView { /// Number of rows [`Self::item_sizes`] was built for (the filtered repo count). repo_len: usize, /// Indices into the store's `announcements` matching [`Self::filter`], - /// in display order; rebuilt when the store changes or the filter is switched. - /// The virtual list renders this slice. + /// in display order; rebuilt when the store changes, the filter is + /// switched, or the search text changes. The virtual list renders this + /// slice. visible: Vec, + /// Search box filtering repositories by name. + search: Entity, + /// Rebuilds the visible slice as the search text changes. + _search_subscription: Subscription, _subscription: Subscription, } impl RepoListView { pub fn new( dock_area: WeakEntity, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Self { let store = RepoListStore::global(cx); - // Keep the visible slice and row sizes in sync with the store, so - // newly announced repositories appear without waiting for a click. + // Live search over repository names + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); + let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { + if matches!(event, InputEvent::Change) { + this.rebuild_rows(cx); + } + }); + + // Keep the visible slice and row sizes in sync with the store, + // so newly announced repositories appear without waiting for a click. let subscription = cx.observe(&store, |this, _store, cx| { this.rebuild_rows(cx); }); @@ -101,6 +139,8 @@ impl RepoListView { item_sizes: Rc::new(Vec::new()), repo_len: 0, visible: Vec::new(), + search, + _search_subscription: search_subscription, _subscription: subscription, }; @@ -113,13 +153,15 @@ impl RepoListView { } /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current - /// store contents and [`Self::filter`]. Called when the view is created, - /// when the store changes, and when the filter is switched, - /// so the list is ready before the next render. + /// store contents, [`Self::filter`] and the search query. Called when + /// the view is created, when the store changes, when the filter is + /// switched, and on every search keystroke, so the list is ready before + /// the next render. fn rebuild_rows(&mut self, cx: &mut Context) { let filter = self.filter; + let query = self.search.read(cx).value(); let store = self.store.read(cx); - self.visible = filter.visible(store); + self.visible = filter.visible(store, &query); // Each virtual list row holds `COLUMNS` repo cards. let rows = self.visible.len().div_ceil(COLUMNS); @@ -252,18 +294,44 @@ impl RepoListView { .into_any_element() } - /// Header of the explore list: title, the count of visible repositories and the sort filter buttons. - fn render_header(&self, cx: &mut Context) -> AnyElement { + fn render_header(&self, count: usize, cx: &mut Context) -> AnyElement { h_flex() .px_4() .py_2() .w_full() .gap_3() - .items_center() - .child(self.filter_button(RepoFilter::All, "All", cx)) - .child(self.filter_button(RepoFilter::Popular, "Popular", cx)) - .child(self.filter_button(RepoFilter::Recent, "Recent", cx)) + .child( + h_flex() + .gap_1() + .text_xs() + .child(div().font_semibold().child("Repositories")) + .child( + div() + .w_10() + .min_w_0() + .truncate() + .text_ellipsis() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("({count})"))), + ), + ) + .child( + Input::new(&self.search) + .cleanable(true) + .w(px(180.)) + .text_sm() + .border_color(cx.theme().muted) + .bg(cx.theme().muted) + .prefix(Icon::new(IconName::Search).small()), + ) .child(div().flex_1()) + .child( + h_flex() + .gap_1() + .child(self.filter_button(RepoFilter::All, "All", cx)) + .child(self.filter_button(RepoFilter::Popular, "Popular", cx)) + .child(self.filter_button(RepoFilter::Recent, "Recent", cx)), + ) .into_any_element() } @@ -275,22 +343,22 @@ impl RepoListView { label: &'static str, cx: &mut Context, ) -> AnyElement { + let active = self.filter == filter; + BaseButton::new(label) .flex() .items_center() .h_7() .px_2() .gap_1() + .child(Icon::new(filter.icon_name())) .child(div().text_sm().child(label)) .text_color(cx.theme().button_foreground) .rounded(cx.theme().radius) .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().primary_active)) - .selected(self.filter == filter) - .when(self.filter == filter, |this| { - this.bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - }) + .active(|this| this.bg(cx.theme().button_active)) + .selected(active) + .when(active, |this| this.bg(cx.theme().button_active)) .on_click(cx.listener(move |this, _event, _window, cx| { this.filter = filter; this.rebuild_rows(cx); @@ -331,8 +399,7 @@ impl Render for RepoListView { .relative() .image_cache(image_cache("repos", MAX_IMAGES)) .size_full() - .pb_3() - .child(self.render_header(cx)) + .child(self.render_header(count, cx)) .when(!has_repos, |this| { this.child( v_flex().size_full().items_center().justify_center().child( diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 8710219..001c688 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -214,7 +214,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("Loading your grasp servers…"), + .child("Loading your grasp servers..."), ) }, )