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..."),
)
},
)