Author SHA1 Message Date
reya 3a70bb7228 add fetch strategy
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-26 16:18:28 +07:00
reya 80d4ca754b add strategy setting 2026-09-26 15:58:49 +07:00
reya 5a74bf1f0d update initial sync 2026-09-26 15:07:05 +07:00
reya da22d26019 fix: date repositories from their state (#23)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #23
2026-09-25 13:13:35 +00:00
reya 0ff6740a9e feat: add an option to enable navigation buttons
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-25 16:49:09 +07:00
reya e2bce4e074 feat: redesign the dock and tabbar (#22)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #22
2026-09-25 08:37:18 +00:00
34 changed files with 1439 additions and 1315 deletions
+11
View File
@@ -6,10 +6,21 @@
### Added
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
### Changed
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
### Fixed
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
### Removed
### Deprecated
Generated
+561 -514
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -9,15 +9,13 @@ edition = "2024"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
gpui = { package = "gpui-pre", version = "0.3.6" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" }
# GPUI Kit
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] }
gpui-base = "0.6.6"
dock = { path = "crates/dock" }
settings = { path = "crates/settings" }
+6 -6
View File
@@ -77,9 +77,9 @@
"success.foreground": "#052E16",
"switch.background": "#CECECE",
"switch.thumb.background": "#FFFFFF",
"tab.background": "#00000000",
"tab.active.background": "#EBFFC1",
"tab.active.foreground": "#3F6212",
"tab.background": "#E8E8E8",
"tab.active.background": "#F5F5F5",
"tab.active.foreground": "#18181B",
"tab.foreground": "#646464",
"tab_bar.background": "#F0F0F0",
"tab_bar.segmented.background": "#F0F0F0",
@@ -286,9 +286,9 @@
"success.foreground": "#052E16",
"switch.background": "#484848",
"switch.thumb.background": "#0A0A0A",
"tab.background": "#00000000",
"tab.active.background": "#19200A",
"tab.active.foreground": "#C6FF4D",
"tab.background": "#2E2E2E",
"tab.active.background": "#2A2A2A",
"tab.active.foreground": "#FAFAFA",
"tab.foreground": "#B4B4B4",
"tab_bar.background": "#191919",
"tab_bar.segmented.background": "#191919",
+1
View File
@@ -9,6 +9,7 @@ publish.workspace = true
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
settings = { path = "../settings" }
signed_ui = { path = "../signed_ui" }
[dev-dependencies]
+2 -30
View File
@@ -11,22 +11,19 @@ use gpui::{
};
use gpui_base::dock::{
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
PanelView, TabGroupRenderer, TilesRenderer,
PanelView, TabGroupRenderer,
};
use gpui_base::resize_handle;
use gpui_component::scroll::ScrollbarMode;
use gpui_component::{ActiveTheme as _, Side};
use crate::invalid_panel::InvalidPanel;
use crate::panel_handle;
use crate::tab_panel::SignedTabGroupSkin;
use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle};
/// State the skin shares with its per-container renderers.
pub(crate) struct SkinShared {
area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
tiles_scrollbar_mode: Cell<Option<ScrollbarMode>>,
/// The dock whose resize handle is being dragged, if any. Only one can be.
resizing_dock: Cell<Option<DockPlacement>>,
}
@@ -40,10 +37,6 @@ impl SkinShared {
self.toggle_button_visible.get()
}
pub(crate) fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
self.tiles_scrollbar_mode.get()
}
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock
}
@@ -73,7 +66,6 @@ impl SignedDockSkin {
shared: Rc::new(SkinShared {
area: cx.weak_entity(),
toggle_button_visible: Cell::new(true),
tiles_scrollbar_mode: Cell::new(None),
resizing_dock: Cell::new(None),
}),
})
@@ -92,16 +84,6 @@ impl SignedDockSkin {
self.shared.toggle_button_visible.set(visible);
self.shared.notify(cx);
}
/// When a tiles canvas shows its scrollbar. `None` follows the theme.
pub fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
self.shared.tiles_scrollbar_mode()
}
pub fn set_tiles_scrollbar_mode(&self, mode: Option<ScrollbarMode>, cx: &mut App) {
self.shared.tiles_scrollbar_mode.set(mode);
self.shared.notify(cx);
}
}
/// Payload a dock's resize handle drags.
@@ -154,16 +136,10 @@ impl DockAreaRenderer for SignedDockSkin {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let placement = dock.placement();
div()
.flex()
.size_full()
.relative()
// A closed bottom dock keeps a strip, and that strip is the tab bar.
.when(!dock.is_open() && placement.is_bottom(), |this| {
this.h(TAB_BAR_HEIGHT)
})
.child(content)
.child(self.render_resize_handle(dock, window, cx))
.child(DockResizeTracker {
@@ -190,10 +166,6 @@ impl DockAreaRenderer for SignedDockSkin {
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
}
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
Rc::new(SignedTilesSkin::new(self.shared().clone()))
}
}
impl SignedDockSkin {
-1
View File
@@ -6,7 +6,6 @@ use gpui_base::dock::PanelView;
mod dock_area;
mod invalid_panel;
mod tab_panel;
mod tiles;
mod window_controls;
pub use dock_area::SignedDockSkin;
+305 -174
View File
@@ -5,9 +5,9 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
Styled as _, Window, div, px, size,
};
use gpui_base::dock::{
@@ -18,17 +18,24 @@ use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
use gpui_component::animation::{Lerp as _, ease_out_cubic};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
use gpui_component::menu::DropdownMenu as _;
use gpui_component::{
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
};
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
use settings::{SettingsStore, TabBarSettings};
use signed_ui::title_bar_drag_handlers;
use crate::dock_area::SkinShared;
use crate::{TAB_BAR_HEIGHT, t, window_controls};
/// The drag preview's size, reported to base for the drop placeholder.
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
/// Whether a group's prev/next buttons should be drawn.
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
}
/// A panel's title, or its registered name when the panel has no handle.
pub(crate) fn panel_title(
@@ -43,24 +50,20 @@ pub(crate) fn panel_title(
}
/// The preview that follows the cursor while a panel is dragged.
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
struct DragPanelPreview {
panel: Arc<dyn BasePanelView>,
}
impl Render for DragPanelPreview {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
h_flex()
.id("drag-panel")
.cursor_grab()
.py_1()
.px_3()
.w_24()
.overflow_hidden()
.p_1()
.whitespace_nowrap()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.rounded(cx.theme().radius_lg)
.text_color(cx.theme().tab_foreground)
.bg(cx.theme().tokens.tab_active)
.opacity(0.75)
@@ -69,9 +72,14 @@ impl Render for DragPanelPreview {
}
/// The zoom affordance for the group's displayed panel, if it offers one.
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?;
panel_zoom_control(group.active_panel()?, cx)
}
/// The zoom affordance `panel` offers, if any.
///
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
panel
.zoomable(cx)
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
@@ -79,16 +87,17 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
}
/// The left-most, top-most tab group in a container.
///
/// A left dock's collapse button lives in this group.
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
PaneRef::Tiles { .. } => None,
}
}
/// The right-most, top-most tab group.
///
/// A vertical split picks its first child, a horizontal split picks its last.
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
@@ -98,7 +107,53 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
gpui::Axis::Horizontal => children.last(),
}
.and_then(right_top_group),
PaneRef::Tiles { .. } => None,
}
}
/// The bottom or right dock whose root tab group is this one, if any.
///
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
fn dock_root_group(
shared: &SkinShared,
group: &TabGroupContext,
cx: &App,
) -> Option<DockPlacement> {
let area = shared.area().upgrade()?;
let area = area.read(cx);
[DockPlacement::Bottom, DockPlacement::Right]
.into_iter()
.find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.root().id() == group.node())
})
}
/// Close `panel`, removing its dock instead when base would refuse an empty one.
///
/// A bottom or right dock's only panel is that case, so the whole dock goes.
fn close_panel(
shared: &SkinShared,
group: &TabGroupContext,
panel: &Arc<dyn BasePanelView>,
window: &mut Window,
cx: &mut App,
) {
if !panel.closable(cx) {
return;
}
let dock = (group.panels().len() <= 1)
.then(|| dock_root_group(shared, group, cx))
.flatten();
match dock {
Some(placement) => {
_ = shared.area().update(cx, |area, cx| {
area.remove_dock(placement, window, cx);
});
}
None => group.close(panel.panel_id(cx), window, cx),
}
}
@@ -144,26 +199,14 @@ impl SignedTabGroupSkin {
left == group.node() && group.panels().len() == 1
}
/// The bottom or right dock whose root tab group is this one, if any.
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
[DockPlacement::Bottom, DockPlacement::Right]
.into_iter()
.find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.root().id() == group.node())
})
}
/// The tab's drag payload, or `None` when the group must not be rearranged.
///
/// A locked group never is, a bottom or right dock root always is.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() {
return None;
}
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
return None;
}
group.drag_panel(ix, cx)
@@ -233,15 +276,22 @@ impl SignedTabGroupSkin {
}
/// The previous and next tab buttons in the tab bar's leading prefix.
/// Always rendered, disabled at the strip ends or when collapsed.
fn render_prev_next_tab_buttons(
&self,
group: &TabGroupContext,
_cx: &mut App,
) -> impl IntoElement {
///
/// Hidden by default, and only offered for crowded strips where the active
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
let collapsed = group.is_collapsed();
let active_ix = group.active_ix();
let panels_len = group.panels().len();
let hidden = SettingsStore::try_global(cx)
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
if !navigation_buttons_visible(hidden, panels_len) {
return Empty.into_any_element();
}
let prev_enabled = !collapsed && active_ix > 0;
let next_enabled = !collapsed && active_ix + 1 < panels_len;
@@ -273,100 +323,10 @@ impl SignedTabGroupSkin {
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
}),
)
}
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
fn render_toolbar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
if group.is_collapsed() {
return div();
}
let zoomed = group.is_zoomed();
let handle = group.active_panel().and_then(PanelHandle::of);
let control = zoom_control(group, cx);
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = control.is_some_and(|control| control.menu_visible());
// A bottom or right dock's only panel cannot close through the group.
// The close item is offered, the skin removes the whole dock instead.
let closable = group.is_closable()
|| (self.is_dock_root_group(group, cx).is_some()
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
let panel = handle.map(|handle| handle.panel());
h_flex()
.gap_1()
.occlude()
.when_some(buttons, |this, buttons| {
this.children(
buttons
.into_iter()
.map(|button| button.small().ghost().tab_stop(false)),
)
})
.map(|this| {
let value = if zoomed {
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
} else if toolbar_zoom {
Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In")))
} else {
None
};
if let Some((id, icon, tooltip)) = value {
this.child(
Button::new(id)
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.tooltip_with_action(tooltip, &ToggleZoom, None)
.selected(zoomed)
.on_click({
let group = group.clone();
move |_, window, cx| group.toggle_zoom(window, cx)
}),
)
} else {
this
}
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.small()
.ghost()
.tab_stop(false)
.dropdown_menu(move |menu, window, cx| {
menu.when_some(panel.clone(), |menu, panel| {
panel.dropdown_menu(menu, window, cx)
})
.separator()
.menu_with_disabled(
if zoomed {
t("Dock.Zoom Out")
} else {
t("Dock.Zoom In")
},
Box::new(ToggleZoom),
!menu_zoom,
)
.when(closable, |menu| {
menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
})
})
.anchor(Anchor::TopRight),
)
.into_any_element()
}
/// One tab of the pill strip.
/// While collapsed, tabs lose the active style and all interactions.
/// The strip is also how a closed bottom dock is opened again.
#[allow(clippy::too_many_arguments)]
fn render_tab(
&self,
@@ -382,25 +342,73 @@ impl SignedTabGroupSkin {
let droppable = group.is_droppable();
let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel);
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
let closable = !collapsed
&& (group.is_closable()
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
// Whether a double-click zooms this tab in.
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
Tab::new(ix)
.h_6()
.px_3()
.text_sm()
.whitespace_nowrap()
.group(TAB_HOVER_GROUP)
.when(active, |this| {
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
})
.flex()
.items_center()
.justify_center()
.gap_1()
.flex_shrink_0()
.h_7()
.min_w_40()
.gap_1()
.px_1p5()
.text_sm()
.overflow_hidden()
.rounded(cx.theme().radius)
.text_color(cx.theme().foreground)
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)),
.text_color(cx.theme().tab_foreground)
.child(
h_flex()
.flex_1()
.h_6()
.items_center()
.overflow_hidden()
.whitespace_nowrap()
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)),
}),
)
.when(closable, |this| {
this.child(
div()
.flex_shrink_0()
.when(active, |this| {
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
})
.when(!active, |this| {
this.invisible()
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
})
.child(
Button::new(id)
.icon(IconName::Close)
.tooltip(t("Dock.Close"))
.ghost()
.xsmall()
.tab_stop(false)
.on_click({
let group = group.clone();
let shared = self.shared.clone();
let panel = panel.clone();
move |_, window, cx| {
cx.stop_propagation();
close_panel(&shared, &group, &panel, window, cx);
}
}),
),
)
})
// Pill style, the selected tab is the filled pill, others show only on hover.
.styles(|styles| {
styles.selected(|style| {
style
@@ -412,15 +420,15 @@ impl SignedTabGroupSkin {
if active {
this
} else {
this.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary_hover)
this.text_color(cx.theme().tab_foreground)
.bg(cx.theme().tab)
}
})
.selected(active)
.on_click({
let group = group.clone();
let area = self.shared.area().clone();
move |_, window, cx| {
move |event, window, cx| {
group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock reopens it.
@@ -428,7 +436,15 @@ impl SignedTabGroupSkin {
_ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, cx);
});
return;
}
// A double-click zooms the tab's panel
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
return;
}
group.toggle_zoom(window, cx);
}
})
.when(!collapsed, |this| {
@@ -531,10 +547,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Base refuses an empty dock, so closing its only panel removes the dock.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
let shared = self.shared.clone();
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
@@ -543,14 +555,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
.size_full()
.overflow_hidden()
.bg(cx.theme().tokens.background)
// A collapsed group has no content, so these actions are not registered.
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = group.clone();
move |_: &ToggleZoom, window, cx| {
// A panel with no zoom control is not zoomed in by the keybinding.
// Zooming out is never refused.
// Otherwise a zoomed panel that lost its control would strand the user.
if !group.is_zoomed() && control.is_none() {
return;
}
@@ -564,18 +572,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let Some(panel) = group.active_panel() else {
return;
};
if !panel.closable(cx) {
return;
}
let panel = panel.panel_id(cx);
match dock_to_remove {
Some(placement) => {
_ = shared.area().update(cx, |area, cx| {
area.remove_dock(placement, window, cx);
});
}
None => group.close(panel, window, cx),
}
close_panel(&shared, &group, panel, window, cx);
}
})
})
@@ -613,17 +610,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// On macOS the traffic lights overlay the window's top-left corner.
// Only the tab bar that sits under them reserves the space.
// That is the center's top-left group when the left dock is closed or absent.
// On macOS the traffic lights overlay the window's top-left corner, so the
// tab bar under them reserves the space. That is the center's top-left group
// when the left dock is closed or absent, and the zoomed group otherwise:
// a zoom fills the whole area, hiding the docks the frame would draw.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
!area.is_dock_open(DockPlacement::Left)
&& area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
match area.zoomed_group() {
Some(zoomed) => zoomed == group.node(),
None => {
!area.is_dock_open(DockPlacement::Left)
&& area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
}
});
// Bring a newly displayed tab into view.
@@ -709,7 +712,16 @@ impl TabGroupRenderer for SignedTabGroupSkin {
.child(
Tabs::new("tab-bar")
.px(px(-1.))
.h(TAB_BAR_HEIGHT)
// A collapsed group stands in for a closed bottom dock,
// which base gives only its reserved strip. The bar keeps
// its own height there rather than the title-bar height,
// so it fits instead of being clipped away.
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
// Only a collapsed (bottom) dock tags its bar, so the
// selector is unique when a test measures it.
.when(collapsed, |this| {
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
})
.flex()
.items_center()
.text_color(cx.theme().tab_foreground)
@@ -763,7 +775,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
.and_then(PanelHandle::of)
.and_then(|handle| handle.title_suffix(window, cx)),
)
.child(self.render_toolbar(group, window, cx))
.children(right_dock_button),
)
}),
@@ -833,3 +844,123 @@ impl TabGroupRenderer for SignedTabGroupSkin {
)
}
}
#[cfg(test)]
mod tests {
use gpui::{
App, AppContext as _, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement,
Render, TestAppContext, Window, px,
};
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
use super::{COLLAPSED_TAB_BAR_SELECTOR, navigation_buttons_visible};
use crate::{BasePanel, Panel, SignedDockSkin, panel_handle};
struct Probe {
focus_handle: FocusHandle,
}
impl Probe {
fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl BasePanel for Probe {
fn panel_name(&self) -> &'static str {
"Probe"
}
}
impl Panel for Probe {
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
"Probe"
}
}
impl EventEmitter<PanelEvent> for Probe {}
impl Focusable for Probe {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// Base reserves only `gpui_base::dock::CLOSED_BOTTOM_STRIP` for a closed
/// bottom dock, so the tab bar drawn in place of one has to shrink to that
/// height; at the full title-bar height its bottom was clipped away.
#[gpui::test]
fn a_collapsed_bottom_dock_bar_fits_the_reserved_strip(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
});
let (area, cx) = cx.add_window_view(|window, cx| {
let skin = SignedDockSkin::new(cx);
DockArea::new("test", None, window, cx).with_renderer(skin)
});
cx.update(|window, cx| {
let center = cx.new(Probe::new);
let bottom = cx.new(Probe::new);
area.update(cx, |area, cx| {
area.set_center(
DockLayout::tabs().panel_view(panel_handle(center), cx),
window,
cx,
);
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel_view(panel_handle(bottom), cx),
window,
cx,
);
});
});
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));
// Open, nothing is collapsed, so no bar carries the selector.
assert!(cx.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR).is_none());
cx.update(|window, cx| {
area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, cx);
});
});
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));
let bounds = cx
.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR)
.expect("a collapsed bottom dock draws its stand-in tab bar");
// `gpui_base::dock::CLOSED_BOTTOM_STRIP`, which the engine does not
// re-export. The bar used to be `TAB_BAR_HEIGHT` here and its bottom was
// clipped away; it now has to fit inside what base reserves.
let reserved_strip = px(29.);
assert!(
bounds.size.height <= reserved_strip,
"the collapsed bar is {}px tall, more than the {}px strip base reserves",
bounds.size.height,
reserved_strip,
);
}
#[test]
fn navigation_buttons_follow_the_setting_and_tab_count() {
// Hidden by default, no matter how many tabs there are.
assert!(!navigation_buttons_visible(true, 1));
assert!(!navigation_buttons_visible(true, 20));
// When not hidden they still only appear for a crowded strip.
assert!(!navigation_buttons_visible(false, 10));
assert!(navigation_buttons_visible(false, 11));
}
}
-388
View File
@@ -1,388 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Div, DragMoveEvent, Empty, InteractiveElement as _,
IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, ScrollHandle,
Size, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, px,
};
use gpui_base::dock::{
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::dock::PanelHandle;
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
};
use crate::dock_area::SkinShared;
use crate::t;
use crate::tab_panel::panel_title;
/// How far a resize handle sticks out past the tile's edge.
const HANDLE_OFFSET: Pixels = px(-4.);
/// The payload a tile drag carries, so one canvas ignores another's drags.
#[derive(Clone)]
struct DragMoving(NodeId);
impl Render for DragMoving {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// The payload a tile resize carries, for the same reason.
#[derive(Clone)]
struct DragResizing(NodeId);
impl Render for DragResizing {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// One tiles canvas's appearance.
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
pub(crate) struct SignedTilesSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
}
impl SignedTilesSkin {
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::default(),
}
}
fn resize_handle(
&self,
tile: &TileContext,
id: &'static str,
side: ResizeSide,
build: impl FnOnce(Stateful<Div>) -> Stateful<Div>,
) -> Stateful<Div> {
let node = tile.node();
build(div().id(id).absolute())
.on_mouse_down(MouseButton::Left, {
let tile = tile.clone();
move |event: &MouseDownEvent, window, cx| {
tile.begin_resize(side, event.position, window, cx);
cx.stop_propagation();
}
})
.on_drag(DragResizing(node), |drag, _, _, cx| {
cx.stop_propagation();
cx.new(|_| drag.clone())
})
.on_drag_move({
let tile = tile.clone();
move |event: &DragMoveEvent<DragResizing>, window, cx| {
if event.drag(cx).0 != node {
return;
}
tile.resize_to(event.event.position, window, cx);
}
})
}
/// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
/// They use click handlers, the zoom and close actions target a focused tab group.
fn render_tile_controls(
&self,
tile: &TileContext,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let handle = PanelHandle::of(tile.panel());
let control = handle.and_then(|handle| handle.zoom_control(cx));
let zoomed = tile.is_zoomed();
let toolbar_zoom =
tile.is_zoomable() && control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = tile.is_zoomable() && control.is_some_and(|control| control.menu_visible());
let closable = tile.is_closable();
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
let panel = handle.map(|handle| handle.panel());
h_flex()
.gap_1()
.flex_shrink_0()
.occlude()
.when_some(buttons, |this, buttons| {
this.children(
buttons
.into_iter()
.map(|button| button.xsmall().ghost().tab_stop(false)),
)
})
.when_some(
match (zoomed, toolbar_zoom) {
(true, _) => Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))),
(false, true) => Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))),
(false, false) => None,
},
|this, (id, icon, tooltip)| {
this.child(
Button::new(id)
.icon(icon)
.xsmall()
.ghost()
.tab_stop(false)
.tooltip(tooltip)
.selected(zoomed)
.on_click({
let tile = tile.clone();
move |_, window, cx| tile.toggle_zoom(window, cx)
}),
)
},
)
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.xsmall()
.ghost()
.tab_stop(false)
.dropdown_menu({
let tile = tile.clone();
move |menu, window, cx| {
menu.when_some(panel.clone(), |menu, panel| {
panel.dropdown_menu(menu, window, cx)
})
.separator()
.item(
PopupMenuItem::new(match zoomed {
true => t("Dock.Zoom Out"),
false => t("Dock.Zoom In"),
})
.disabled(!menu_zoom && !zoomed)
.on_click({
let tile = tile.clone();
move |_, window, cx| tile.toggle_zoom(window, cx)
}),
)
.when(closable, |menu| {
menu.separator()
.item(PopupMenuItem::new(t("Dock.Close")).on_click({
let tile = tile.clone();
move |_, window, cx| tile.close(window, cx)
}))
})
}
})
.anchor(gpui::Anchor::TopRight),
)
}
}
impl TilesRenderer for SignedTilesSkin {
fn frame(&self, _: &mut Window, cx: &mut App) -> Stateful<Div> {
div()
.id("tiles")
.relative()
.size_full()
.bg(cx.theme().tokens.tiles)
.track_scroll(&self.scroll_handle)
.overflow_scroll()
}
fn tile_frame(&self, tile: &TileContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
v_flex()
.id(("tile", tile.panel_id().as_u64()))
.occlude()
.overflow_hidden()
.bg(cx.theme().tokens.background)
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().tile_radius)
// Room for the title bar, which overlays the top padding.
// Base draws the panel as a plain child, this keeps them apart.
.pt(DRAG_BAR_HEIGHT)
// Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
.when(tile.is_zoomed(), |this| this.size_full())
.on_mouse_down(MouseButton::Left, {
let tile = tile.clone();
move |_, window, cx| tile.bring_to_front(window, cx)
})
// A gesture can end anywhere, so both mouse-up hooks run.
// Each is a no-op unless this tile is the one that moved.
.on_mouse_up(MouseButton::Left, {
let tile = tile.clone();
move |_, window, cx| {
tile.end_move(window, cx);
tile.end_resize(window, cx);
}
})
.on_mouse_up_out(MouseButton::Left, {
let tile = tile.clone();
move |_, window, cx| {
tile.end_move(window, cx);
tile.end_resize(window, cx);
}
})
}
fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement {
let node = tile.node();
let handle = PanelHandle::of(tile.panel());
let title_style = handle.and_then(|handle| handle.title_style(cx));
h_flex()
.id("drag-bar")
.absolute()
.top_0()
.left_0()
.w_full()
.h(DRAG_BAR_HEIGHT)
.items_center()
.gap_1()
.pl_3()
.pr_2()
.when_some(title_style, |this, style| {
this.bg(style.background).text_color(style.foreground)
})
.child(
div()
.flex_1()
.min_w_16()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.child(panel_title(tile.panel(), window, cx)),
)
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
.child(self.render_tile_controls(tile, window, cx))
// A zoomed tile is not at its stored bounds, so moving it would mean nothing.
.when(!tile.is_zoomed(), |this| {
this.cursor_grab()
.on_mouse_down(MouseButton::Left, {
let tile = tile.clone();
move |event: &MouseDownEvent, window, cx| {
tile.begin_move(event.position, window, cx);
}
})
.on_drag(DragMoving(node), |drag, _, _, cx| {
cx.stop_propagation();
cx.new(|_| drag.clone())
})
.on_drag_move({
let tile = tile.clone();
move |event: &DragMoveEvent<DragMoving>, window, cx| {
if event.drag(cx).0 != node {
return;
}
tile.move_to(event.event.position, window, cx);
}
})
})
.into_any_element()
}
fn render_resize_handles(
&self,
tile: &TileContext,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
let bounds = tile.bounds();
// A passive full-tile box, so handles sit against the tile, not its flow neighbours.
// It registers no interaction, so it does not shadow the panel underneath.
div()
.absolute()
.top_0()
.left_0()
.size_full()
.child(
self.resize_handle(tile, "left-resize-handle", ResizeSide::Left, |this| {
this.cursor_ew_resize()
.top_0()
.left(HANDLE_OFFSET)
.w(HANDLE_SIZE)
.h(bounds.size.height)
}),
)
.child(
self.resize_handle(tile, "right-resize-handle", ResizeSide::Right, |this| {
this.cursor_ew_resize()
.top_0()
.right(HANDLE_OFFSET)
.w(HANDLE_SIZE)
.h(bounds.size.height)
}),
)
.child(
self.resize_handle(tile, "top-resize-handle", ResizeSide::Top, |this| {
this.cursor_ns_resize()
.left_0()
.top(HANDLE_OFFSET)
.w(bounds.size.width)
.h(HANDLE_SIZE)
}),
)
.child(
self.resize_handle(tile, "bottom-resize-handle", ResizeSide::Bottom, |this| {
this.cursor_ns_resize()
.left_0()
.bottom(HANDLE_OFFSET)
.w(bounds.size.width)
.h(HANDLE_SIZE)
}),
)
.child(
Icon::new(IconName::ResizeCorner)
.size_3()
.absolute()
.right(px(1.))
.bottom(px(1.))
.text_color(cx.theme().muted_foreground.opacity(0.5)),
)
.child(self.resize_handle(
tile,
"corner-resize-handle",
ResizeSide::BottomRight,
|this| {
this.cursor_nwse_resize()
.right(HANDLE_OFFSET)
.bottom(HANDLE_OFFSET)
.size_3()
},
))
.into_any_element()
}
/// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
h_flex()
.id(("tile-panel", tile.panel_id().as_u64()))
.overflow_hidden()
.size_full()
}
/// The canvas scrollbar, as an overlay.
/// Placed inside the frame it would end up underneath every tile.
fn render_overlay(
&self,
content: Size<Pixels>,
_: &mut Window,
_: &mut App,
) -> Option<AnyElement> {
Some(
Scrollbar::new(&self.scroll_handle)
.scroll_size(content)
.when_some(self.shared.tiles_scrollbar_mode(), |this, mode| {
this.mode(mode)
})
.into_any_element(),
)
}
fn grid_size(&self, cx: &App) -> Pixels {
cx.theme().tile_grid_size
}
}
+39
View File
@@ -18,6 +18,16 @@ pub enum AppearanceMode {
Dark,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventFetchingStrategy {
/// Only the relays declared in the repository announcement.
Curated,
/// Repository relays plus every maintainer's NIP-65 relays.
#[default]
Uncensored,
}
/// Fields mirror the gpui-component `Theme` surface customized at startup.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -54,6 +64,22 @@ impl Default for ThemeSettings {
}
}
/// Tab bar chrome.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TabBarSettings {
/// Hide the previous and next tab buttons.
pub hide_navigation_buttons: bool,
}
impl Default for TabBarSettings {
fn default() -> Self {
Self {
hide_navigation_buttons: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GraspServersSettings {
@@ -125,7 +151,9 @@ pub struct CreateRepositorySettings {
#[serde(default)]
pub struct Settings {
pub appearance: AppearanceMode,
pub event_fetching: EventFetchingStrategy,
pub theme: ThemeSettings,
pub tab_bar: TabBarSettings,
pub grasp_servers: GraspServersSettings,
pub local_repos: LocalReposSettings,
pub checkouts: CheckoutsSettings,
@@ -140,6 +168,7 @@ mod tests {
fn json_roundtrip_preserves_everything() {
let settings = Settings {
appearance: AppearanceMode::Dark,
event_fetching: EventFetchingStrategy::Curated,
theme: ThemeSettings {
radius: 8.0,
..Default::default()
@@ -161,9 +190,19 @@ mod tests {
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
assert_eq!(settings.appearance, AppearanceMode::Dark);
assert_eq!(settings.theme.radius, 4.0);
// Unset fields fall back to their defaults, including the fetching strategy.
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
// The rest of the theme and the other groups keep their defaults.
assert_eq!(settings.theme.light_theme, "Signed Light");
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
assert_eq!(settings.create_repository.default_folder, None);
}
#[test]
fn event_fetching_uses_snake_case() {
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
assert_eq!(json, r#""curated""#);
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
}
}
+6
View File
@@ -24,6 +24,12 @@ impl SettingsStore {
cx.global::<GlobalSettingsStore>().0.clone()
}
/// Retrieve the global settings store if one has been installed.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalSettingsStore>()
.map(|store| store.0.clone())
}
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalSettingsStore(entity));
}
+5
View File
@@ -184,6 +184,11 @@ pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement)
}
/// All repository state events, carrying each repository's refs and last push time.
pub fn all_states() -> Filter {
Filter::new().kind(Kind::RepoState)
}
/// How far back deletion requests are fetched and stored.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
+42 -31
View File
@@ -23,12 +23,7 @@ pub const USER_KEYRING: &str = "Signed Safe Storage";
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
/// Relays connected at startup, before any user-specific relay config is known.
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://index.ngit.dev",
"wss://profiles.nostr1.com",
];
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
/// Relays used to index the user's NIP-65 relay list.
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
@@ -43,11 +38,6 @@ pub enum BackendEvent {
/// The signer changed on login, logout or account switch.
SignerChanged,
/// New events were received from a relay and stored in the database.
///
/// Batched: [`Backend`]'s notification pump coalesces everything a
/// relay delivers within one debounce window into a single event,
/// instead of emitting per-event and making every subscriber debounce
/// the same burst independently.
NostrUpdate(Vec<Update>),
Synced,
SyncProgress {
@@ -446,9 +436,6 @@ impl Backend {
let client = self.client.clone();
// Initialize directly at the user's chosen destination.
// No mirror is pre-populated: `GitCache::ensure_clone` lazily clones
// from the grasp server the first time the repo detail view needs it,
// exactly like every other repository.
let destination = {
let dir_name = signed_git::sanitize_path_component(&name);
let dir_name = if dir_name.is_empty() {
@@ -1205,6 +1192,21 @@ impl Backend {
.detach();
}
/// Sync filters through the SDK's NIP-65 gossip targeting.
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
cx.spawn(async move |_this, _cx| {
for filter in filters {
if let Err(e) = client.sync(filter).await {
log::warn!("gossip relay fetch failed: {e}");
}
}
Ok::<(), Error>(())
})
.detach();
}
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1222,14 +1224,15 @@ impl Backend {
.detach();
}
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
/// Sync several bootstrap filters in order, within a single task.
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
let (tx, mut rx) = SyncProgress::channel();
self.sync_progress = Some((0, 0));
cx.notify();
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
let mut last_percent: u64 = 0;
while rx.changed().await.is_ok() {
@@ -1254,23 +1257,29 @@ impl Backend {
}
}
Ok(())
});
progress_task.detach();
Ok::<(), anyhow::Error>(())
})
.detach();
let sync = cx.background_spawn(async move {
let opts = SyncOptions::default().progress(tx);
sync_bootstrap_only(&client, filter, opts).await
let mut first_error = None;
for filter in filters {
let opts = SyncOptions::default().progress(tx.clone());
if let Err(error) = sync_bootstrap_only(&client, filter, opts).await {
first_error.get_or_insert(error);
}
}
match first_error {
Some(error) => Err(error),
None => Ok(()),
}
});
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
match sync.await {
Ok(summary) => {
log::debug!(
"sync done: {} received, {} sent",
summary.received.len(),
summary.sent.len()
);
Ok(_) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
cx.emit(BackendEvent::Synced);
@@ -1284,9 +1293,10 @@ impl Backend {
})?;
}
}
Ok(())
});
task.detach();
Ok::<(), anyhow::Error>(())
})
.detach();
}
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
@@ -1423,6 +1433,7 @@ pub(crate) async fn sync_bootstrap_only(
.with(BOOTSTRAP_RELAYS)
.opts(opts)
.await?;
Ok(output.value)
}
+158 -59
View File
@@ -7,6 +7,7 @@ use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
use nostr::event::IntoEventBuilder;
use nostr_sdk::prelude::*;
use settings::{EventFetchingStrategy, SettingsStore};
use signed_core::{
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
pull_request_patches,
@@ -84,6 +85,10 @@ pub struct RepoStore {
///
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
root_fetches: HashSet<EventId>,
/// Maintainers already synced through gossip in Uncensored mode.
///
/// Avoids re-running the maintainer Auto sync on every refresh.
synced_maintainers: HashSet<PublicKey>,
refresh: RefreshGate,
/// Backend subscription of an announced repository. `None` while local-only.
_subscription: Option<Subscription>,
@@ -132,6 +137,7 @@ impl RepoStore {
cloning: false,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
synced_maintainers: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: Some(subscription),
}
@@ -160,6 +166,7 @@ impl RepoStore {
cloning: false,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
synced_maintainers: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: None,
}
@@ -205,29 +212,32 @@ impl RepoStore {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(addr);
let author = update.author == addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag.
// Coordinate matching fails for them.
// Any comment may reference this repository's roots.
let authored = (update.kind == Kind::GitRepoAnnouncement
|| update.kind == Kind::RepoState)
&& author;
let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag, NIP-34.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
deletion || coordinate || authored || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let announcement = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
// Locally published deletions may target any event of this repository.
// Refresh so they take effect immediately, like relay deletions.
let state = event.kind == Kind::RepoState
&& author
&& event.tags.identifier().as_deref() == Some(addr.identifier.as_str());
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) || deletion
coordinate || (announcement && author) || state || deletion
}
_ => false,
};
@@ -256,8 +266,6 @@ impl RepoStore {
/// Announcement, state, activity and deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![
// Announcement and state share author and identifier.
// They combine into one filter, one fewer negentropy reconciliation per relay.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key)
@@ -287,9 +295,71 @@ impl RepoStore {
self.repo_relays.extend(new.iter().cloned());
let backend = Backend::global(cx);
let filters = Self::repo_filters(&addr);
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
backend.connect_repo_relays(new, filters, cx);
});
}
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
let mut pubkeys = maintainers.to_vec();
// NIP-34 events tag the announcement author,
// which may not be a maintainer for subordinate forks.
if !pubkeys.contains(&addr.public_key) {
pubkeys.push(addr.public_key);
}
vec![
// Announcement and state events, including co-maintainer states.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.authors(pubkeys.clone())
.identifier(addr.identifier.clone()),
// Activity tagging a maintainer, resolved to their read relays.
Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.coordinate(addr)
.pubkeys(pubkeys.clone()),
// Activity authored by a maintainer, resolved to their write relays.
Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.coordinate(addr)
.authors(pubkeys.clone()),
// Deletions authored by a maintainer.
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.authors(pubkeys),
]
}
/// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting
fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context<Self>) {
let strategy = SettingsStore::try_global(cx)
.map(|store| store.read(cx).settings().event_fetching)
.unwrap_or_default();
if strategy != EventFetchingStrategy::Uncensored {
return;
}
let Some(addr) = self.addr.clone() else {
return;
};
if !maintainers
.iter()
.any(|public_key| !self.synced_maintainers.contains(public_key))
{
return;
}
self.synced_maintainers.extend(maintainers.iter().copied());
let filters = Self::maintainer_filters(&addr, maintainers);
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.sync_auto(filters, cx);
});
}
@@ -306,9 +376,6 @@ impl RepoStore {
}
/// Re-query the local database and update all fields.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.addr.is_none() {
return;
@@ -345,11 +412,10 @@ impl RepoStore {
let deletions = Deletions::from_events(deletion_events);
// Parse and sort off the main thread.
// Only plain data crosses back into the entity.
let all_announcements = announcements
.into_iter()
.filter(|e| !deletions.is_deleted(e));
let announcement = latest(all_announcements)
.as_ref()
.and_then(Announcement::from_event);
@@ -374,8 +440,6 @@ impl RepoStore {
}
}
// NIP-22 comments reference their root via an `E` or `e` tag.
// Not the repository's `a` tag, so query them by the root events.
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
let db = client.database();
@@ -393,8 +457,6 @@ impl RepoStore {
}
}
// Status events may omit their `a` tag.
// Query them by the root events they reference too.
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
let db = client.database();
@@ -417,9 +479,6 @@ impl RepoStore {
sort_newest_first(&mut pull_requests);
sort_oldest_first(&mut comments);
// Resolve every root's status once here.
// Render paths do HashMap lookups instead of per-root status scans.
// Those scans are quadratic, with an allocation per pair.
let maintainers = announcement
.as_ref()
.map(Announcement::effective_maintainers)
@@ -477,25 +536,13 @@ impl RepoStore {
};
let again = this.update(cx, |this, cx| {
// Compare before moving the freshly queried data in, so a pass
// that found nothing new does not notify observers. The store
// is polled in bursts while a sync is in flight; notifying on
// every identical pass would re-render the repository panel
// several times for no visible change.
//
// The first pass is the exception: it must notify even when it
// found nothing, so views can leave their loading state and show
// the empty result.
//
// Keep the open-time hint until that first pass has confirmed what
// the database holds; afterwards the database is the truth,
// including a deletion.
let keep_hint = announcement.is_none() && !this.loaded;
let first_pass = !this.loaded;
let head_changed = state
.as_ref()
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
let changed = first_pass
|| (!keep_hint && this.announcement != announcement)
|| head_changed
@@ -510,14 +557,23 @@ impl RepoStore {
}
// The announcement may list relays for this repository's activity.
// Connect to any we have not fetched from yet.
let relays = this
.announcement
.as_ref()
.map(|a| a.relays.clone())
.unwrap_or_default();
this.connect_announced_relays(&relays, cx);
// Uncensored mode also covers the maintainers' NIP-65 relays.
let maintainers = this
.announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
this.sync_maintainer_relays(&maintainers, cx);
if let Some((_, head)) = state {
this.head = head;
}
@@ -531,10 +587,6 @@ impl RepoStore {
this.open_pr_count = open_pr_count;
this.loaded = true;
// Comments and statuses without an `a` tag.
// None are addressed to the repository.
// Fetch them by the root events they reference.
// Use the bootstrap relays and the relays this repository announced.
let roots = this
.issues
.iter()
@@ -551,13 +603,13 @@ impl RepoStore {
if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied());
// Batch the per-root filters.
// One filter per root costs a negentropy reconciliation per relay.
let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(root_filters.clone(), cx);
backend.connect_repo_relays(announced, root_filters, cx);
@@ -571,8 +623,6 @@ impl RepoStore {
this.refresh.finish()
})?;
// Requests that arrived while the refresh was running.
// They are coalesced into one follow-up refresh.
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
@@ -588,25 +638,16 @@ impl RepoStore {
}
/// Number of open issues.
///
/// Issues whose resolved status is [`RepoStatus::Open`].
/// Issues without status events default to open.
pub fn issue_count(&self) -> usize {
self.open_issue_count
}
/// Number of open pull requests.
///
/// Only root PR events count, PR updates do not.
/// They must resolve to [`RepoStatus::Open`].
pub fn pull_request_count(&self) -> usize {
self.open_pr_count
}
/// Whether `user` is the author or owner of this repository.
///
/// The author is the public key of the repository address.
/// Only the author may manage pull requests, close, reopen or merge.
pub fn is_author(&self, user: &PublicKey) -> bool {
self.addr
.as_ref()
@@ -1769,9 +1810,11 @@ fn comment_builder(
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use nostr_sdk::prelude::*;
use super::{comment_builder, patch_current_commit};
use super::{RepoStore, comment_builder, patch_current_commit};
#[test]
fn parses_format_patch_header() {
@@ -1817,4 +1860,60 @@ mod tests {
// Signed's own `references_root` must keep matching the comment.
assert!(signed_core::references_root(&event, &root.id));
}
#[test]
fn maintainer_filters_name_owner_and_maintainers() {
let owner = Keys::generate().public_key();
let maintainer = Keys::generate().public_key();
let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo");
// The owner is not among the maintainers, as on a subordinate fork.
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
assert_eq!(filters.len(), 4);
let expected = HashSet::from([owner, maintainer]);
// Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag.
let named = |filter: &Filter| -> HashSet<PublicKey> {
let authors = filter.authors.iter().flatten().copied();
let p_tag = filter
.generic_tags
.get(&SingleLetterTag::LOWERCASE_P)
.into_iter()
.flatten()
.filter_map(|value| PublicKey::from_hex(value).ok());
authors.chain(p_tag).collect()
};
// Announcement and state events, scoped to the repository identifier.
let announcement = &filters[0];
assert_eq!(named(announcement), expected);
assert!(
announcement
.generic_tags
.contains_key(&SingleLetterTag::LOWERCASE_D)
);
// Activity filters, scoped to the repository coordinate.
for filter in &filters[1..3] {
assert_eq!(named(filter), expected);
assert!(
filter
.generic_tags
.contains_key(&SingleLetterTag::LOWERCASE_A)
);
}
// Deletions, named by author only.
let deletions = &filters[3];
assert_eq!(
deletions
.authors
.clone()
.unwrap_or_default()
.into_iter()
.collect::<HashSet<_>>(),
expected
);
}
}
+46 -18
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
@@ -47,6 +47,8 @@ pub struct RepoListStore {
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Own repositories whose state events were fetched from their announced relays.
state_synced_repos: HashSet<RepoAddr>,
refresh: RefreshGate,
_subscription: Subscription,
}
@@ -82,17 +84,16 @@ impl RepoListStore {
}),
BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement;
// Locally published deletions are already in the local database.
// Refresh so they take effect immediately, like relay deletions.
let state = event.kind == Kind::RepoState;
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || deletion
announcement || state || deletion
}
BackendEvent::SignerChanged => {
this.state_synced_repos.clear();
true
}
// Only a completed sync refreshes the list.
// Progress ticks would re-scan the whole database several times
// per sync to reveal entries incrementally.
BackendEvent::Synced => true,
_ => false,
};
@@ -114,6 +115,7 @@ impl RepoListStore {
announcements: Arc::new(Vec::new()),
last_activity: Arc::new(HashMap::new()),
counts: Arc::new(HashMap::new()),
state_synced_repos: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: subscription,
}
@@ -132,12 +134,42 @@ impl RepoListStore {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.sync_bootstrap(filters::all_announcements(), cx);
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
backend.sync_bootstrap(filters::deletions(), cx);
backend.sync_bootstraps(
vec![
filters::all_announcements(),
filters::all_states(),
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
filters::deletions(),
],
cx,
);
});
}
/// Fetch the state events of the user's own repositories.
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
return;
};
let pending: Vec<(RepoAddr, Vec<RelayUrl>)> = self
.announcements
.iter()
.filter(|announcement| announcement.owner == me && !announcement.relays.is_empty())
.map(|announcement| (announcement.addr(), announcement.relays.clone()))
.filter(|(addr, _)| !self.state_synced_repos.contains(addr))
.collect();
for (addr, relays) in pending {
self.state_synced_repos.insert(addr.clone());
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
});
}
}
/// Re-query the local database.
///
/// Runs immediately. The backend pump already batches the relay events that
@@ -189,9 +221,6 @@ impl RepoListStore {
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
// Last activity per repository.
// State updates count, and all NIP-34 activity events.
// The activity events are patches, PRs, issues and statuses.
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
.iter()
.map(|a| (a.addr(), a.created_at))
@@ -217,6 +246,7 @@ impl RepoListStore {
let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.since(Timestamp::now() - ACTIVITY_WINDOW);
for event in client.database().query(activity_filter).await? {
if deletions.is_deleted(&event) {
continue;
@@ -225,8 +255,6 @@ impl RepoListStore {
if addr.kind != Kind::GitRepoAnnouncement {
continue;
}
// Skip events for repos we do not list.
// The map cannot grow beyond the number of announcements.
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
@@ -239,13 +267,12 @@ impl RepoListStore {
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
for event in client.database().query(count_filter).await? {
if deletions.is_deleted(&event) {
continue;
}
for addr in event.tags.coordinates() {
// Skip events for repos we do not list.
// The map cannot grow beyond the number of announcements.
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
{
continue;
@@ -278,6 +305,7 @@ impl RepoListStore {
this.announcements = Arc::new(announcements);
this.last_activity = Arc::new(last_activity);
this.counts = Arc::new(counts);
this.sync_own_repo_states(cx);
cx.notify();
this.refresh.finish()
@@ -1,21 +1,26 @@
use gpui::prelude::*;
use gpui::{App, SharedString, StyleRefinement, Window};
use gpui_component::avatar::Avatar;
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
/// It shows the user's picture or falls back to name initials.
use crate::pixel_avatar::{PixelAvatar, side_length};
/// A user avatar built on the unstyled [`gpui_base::Avatar`].
///
/// It shows the user's picture when one is set, and falls back to the
/// deterministic pixel avatar seeded from the name otherwise.
#[derive(IntoElement)]
pub struct UserAvatar {
pub struct Avatar {
name: SharedString,
picture: Option<SharedString>,
size: Size,
style: StyleRefinement,
}
impl UserAvatar {
impl Avatar {
/// Create an avatar for `name`.
/// The name seeds the initials fallback shown when no picture is set.
///
/// The name seeds the pixel fallback shown when no picture is set.
pub fn new(name: impl Into<SharedString>) -> Self {
Self {
name: name.into(),
@@ -32,26 +37,35 @@ impl UserAvatar {
}
}
impl Sizable for UserAvatar {
impl Sizable for Avatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for UserAvatar {
impl Styled for Avatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for UserAvatar {
impl RenderOnce for Avatar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
Avatar::new()
.name(self.name)
.when_some(self.picture, |this, url| this.src(url))
let fallback = AvatarFallback::new()
.size_full()
.child(PixelAvatar::new(self.name.clone()).size_full());
BaseAvatar::new()
.size(side_length(self.size))
.flex_shrink_0()
.rounded(cx.theme().radius)
.overflow_hidden()
.bg(cx.theme().secondary)
.when_some(self.picture, |this, url| {
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
})
.fallback(fallback)
.refine_style(&self.style)
.with_size(self.size)
}
}
+2 -2
View File
@@ -1,3 +1,4 @@
mod avatar;
mod dropdown_button;
mod nav_item;
mod pixel_avatar;
@@ -8,11 +9,11 @@ mod setting;
mod status_badge;
mod title_bar;
mod tree_row;
mod user_avatar;
pub mod copy_row;
pub mod util;
pub use avatar::Avatar;
pub use copy_row::{copy_row, menu_copy_row};
pub use dropdown_button::DropdownButton;
pub use nav_item::NavItem;
@@ -24,5 +25,4 @@ pub use setting::{SelectOption, setting_block, setting_row};
pub use status_badge::status_badge;
pub use title_bar::title_bar_drag_handlers;
pub use tree_row::tree_row;
pub use user_avatar::UserAvatar;
pub use util::middle_truncate;
+22 -14
View File
@@ -5,7 +5,10 @@ use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
/// Number of rows and columns in the pixel grid.
const GRID_SIZE: usize = 8;
/// Probability that a cell in the left half is filled.
/// Empty cells kept between the pattern and the avatar edge, so the art
/// gathers in the center instead of filling the whole avatar.
const MARGIN: usize = 1;
/// Probability that a cell in the left half of the pattern area is filled.
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
@@ -22,12 +25,10 @@ pub struct PixelAvatar {
impl PixelAvatar {
/// Create an avatar seeded from `seed`.
///
/// The seed should be a stable string unique to the entity the avatar represents.
pub fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::XSmall,
size: Size::Small,
style: StyleRefinement::default(),
}
}
@@ -79,19 +80,21 @@ impl RenderOnce for PixelAvatar {
}
div()
.refine_style(&self.style)
.grid()
.grid_cols(GRID_SIZE as u16)
.grid_rows(GRID_SIZE as u16)
.size(side_length(self.size))
.flex_shrink_0()
.rounded(theme.radius)
.overflow_hidden()
.bg(main.opacity(0.16))
.children(cells)
.refine_style(&self.style)
}
}
fn side_length(size: Size) -> Pixels {
/// The rendered side length of an avatar at `size`, shared with [`Avatar`].
pub(crate) fn side_length(size: Size) -> Pixels {
match size {
Size::XSmall => px(16.),
Size::Small => px(24.),
@@ -106,8 +109,13 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
let mut filled = 0usize;
for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE / 2 {
// Only the inner rows and the inner left half are candidates; mirroring
// then keeps the art within the same inset, leaving the outer ring empty.
let art_rows = GRID_SIZE - 2 * MARGIN;
let art_columns = GRID_SIZE / 2 - MARGIN;
for row in MARGIN..GRID_SIZE - MARGIN {
for col in MARGIN..GRID_SIZE / 2 {
if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
@@ -117,17 +125,17 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
}
if filled < MIN_FILLED {
let half = GRID_SIZE * GRID_SIZE / 2;
let start = (rng.next() % half as u64) as usize;
let total = art_rows * art_columns;
let start = (rng.next() % total as u64) as usize;
for offset in 0..half {
for offset in 0..total {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % half;
let row = ix / (GRID_SIZE / 2);
let col = ix % (GRID_SIZE / 2);
let ix = (start + offset) % total;
let row = MARGIN + ix / art_columns;
let col = MARGIN + ix % art_columns;
if pattern[row * GRID_SIZE + col] == 0 {
set_cell(&mut pattern, row, col, 1);
+10 -5
View File
@@ -18,10 +18,12 @@ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
use signed_state::RepoStore;
use signed_ui::{placeholder, tree_row};
use utils::relative_time_secs;
use crate::views::tree::{build_tree_items, tree_items};
use crate::views::{repo_tab_avatar, tab_title};
const TREE_WIDTH: f32 = 260.;
@@ -293,6 +295,7 @@ impl Render for DiffPane {
pub struct CommitDiffView {
focus_handle: FocusHandle,
store: Entity<RepoStore>,
worktree: PathBuf,
repo_name: SharedString,
commit: FileCommit,
@@ -304,6 +307,7 @@ pub struct CommitDiffView {
impl CommitDiffView {
pub fn new(
store: Entity<RepoStore>,
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
@@ -319,6 +323,7 @@ impl CommitDiffView {
Self {
focus_handle: cx.focus_handle(),
store,
worktree,
repo_name,
commit: FileCommit {
@@ -465,11 +470,11 @@ impl BasePanel for CommitDiffView {
}
impl Panel for CommitDiffView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from(format!(
"{}/{}",
self.repo_name, self.commit.id
)))
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = repo_tab_avatar(self.store.read(cx), cx);
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
tab_title(avatar, label)
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId, PublicKey};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::UserAvatar;
use signed_ui::Avatar;
use utils::relative_time;
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
@@ -71,7 +71,7 @@ pub(crate) fn sidebar_section(
h_flex()
.gap_1()
.items_center()
.child(UserAvatar::new(name.clone()).picture(picture))
.child(Avatar::new(name.clone()).picture(picture))
.child(div().text_sm().truncate().text_ellipsis().child(name))
.into_any_element()
})),
@@ -137,7 +137,7 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(Avatar::new(author.clone()).picture(picture))
.child(author),
)
.child(
+4 -4
View File
@@ -15,10 +15,10 @@ use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters}
use signed_state::{
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
};
use signed_ui::{CountBadge, UserAvatar};
use signed_ui::{Avatar, CountBadge};
use utils::relative_time;
use super::{RepoItem, open_repo_item};
use super::{RepoItem, open_repo_item, panel_avatar, tab_title};
const LIST_OVERDRAW: Pixels = px(400.);
const MAX_SUB_ACTIVITIES: usize = 5;
@@ -553,7 +553,7 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
.items_center()
.text_xs()
.child(
UserAvatar::new(name.clone())
Avatar::new(name.clone())
.picture(profile.picture())
.xsmall(),
)
@@ -619,7 +619,7 @@ impl BasePanel for InboxView {
impl Panel for InboxView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from("Inbox"))
tab_title(panel_avatar("inbox"), SharedString::from("Inbox"))
}
}
+6 -5
View File
@@ -10,10 +10,11 @@ use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
use nostr::prelude::EventId;
use signed_core::activity_subject;
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{UserAvatar, placeholder, status_badge};
use signed_ui::{Avatar, placeholder, status_badge};
use utils::relative_time;
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
use crate::views::{repo_tab_avatar, tab_title};
pub struct IssueDetailView {
focus_handle: FocusHandle,
@@ -65,7 +66,9 @@ impl Panel for IssueDetailView {
})
.unwrap_or_else(|| SharedString::from("Issue"));
div().text_sm().child(short_id)
let avatar = repo_tab_avatar(self.store.read(cx), cx);
tab_title(avatar, short_id)
}
}
@@ -150,9 +153,7 @@ impl Render for IssueDetailView {
.child(
h_flex()
.gap_1()
.child(
UserAvatar::new(&author).picture(picture),
)
.child(Avatar::new(&author).picture(picture))
.child(author),
)
.child(
+8 -4
View File
@@ -18,13 +18,14 @@ use gpui_component::{
use nostr::prelude::EventId;
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
use utils::relative_time;
pub(super) mod detail;
use self::detail::IssueDetailView;
use super::status_list::{StatusCounts, filter_by_status};
use crate::views::{repo_tab_avatar, tab_title};
const ISSUE_ROW_HEIGHT: f32 = 73.;
@@ -188,7 +189,7 @@ impl IssuesView {
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(Avatar::new(author.clone()).picture(picture))
.child(div().child(author)),
)
.child(SharedString::from("opened"))
@@ -333,8 +334,11 @@ impl BasePanel for IssuesView {
}
impl Panel for IssuesView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!("{}/issues", self.repo_name)))
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = repo_tab_avatar(self.store.read(cx), cx);
let label = SharedString::from(format!("{}/issues", self.repo_name));
tab_title(avatar, label)
}
}
+45
View File
@@ -11,8 +11,53 @@ pub(crate) mod sidebar;
mod status_list;
pub(crate) mod tree;
use gpui::prelude::*;
use gpui::{AnyElement, App, div};
use gpui_component::{Sizable as _, h_flex};
pub use inbox::InboxView;
pub use repo::RepoDetailView;
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel;
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{Avatar, PixelAvatar};
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
h_flex()
.gap_1p5()
.items_center()
.child(div().flex_shrink_0().child(avatar))
.child(label)
}
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
PixelAvatar::new(seed).xsmall().into_any_element()
}
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
let seed = store
.announcement
.as_ref()
.map(|announcement| format!("{}:{}", announcement.owner, announcement.id))
.or_else(|| store.addr().map(|addr| addr.to_string()))
.or_else(|| {
store
.path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
})
.unwrap_or_default();
let owner = store
.announcement
.as_ref()
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
match owner.and_then(|profile| profile.picture().map(|picture| (profile.name(), picture))) {
Some((name, picture)) => Avatar::new(name)
.picture(Some(picture))
.xsmall()
.into_any_element(),
None => PixelAvatar::new(seed).xsmall().into_any_element(),
}
}
@@ -27,11 +27,12 @@ use signed_core::{
};
use signed_git::{FileCommit, patch_commits, patch_diffs};
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
use utils::{relative_time, relative_time_secs};
use crate::views::commit_diff::{CommitDiffView, DiffPane};
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
use crate::views::{repo_tab_avatar, tab_title};
const ROW_HEIGHT: f32 = 37.;
@@ -343,6 +344,7 @@ impl PullRequestDetailView {
let panel = cx.new(|cx| {
CommitDiffView::new(
self.store.clone(),
worktree,
self.repo_name.clone(),
commit_id.into(),
@@ -451,7 +453,7 @@ impl PullRequestDetailView {
h_flex()
.gap_1()
.child(
UserAvatar::new(author.clone())
Avatar::new(author.clone())
.picture(picture),
)
.child(author),
@@ -788,15 +790,17 @@ impl BasePanel for PullRequestDetailView {
}
impl Panel for PullRequestDetailView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = repo_tab_avatar(self.store.read(cx), cx);
let hex = self.pr_id.to_hex();
let id = SharedString::from(&hex[..8]);
let title = if self.repo_name.is_empty() {
let label = if self.repo_name.is_empty() {
id
} else {
SharedString::from(format!("{}/{}", self.repo_name, id))
};
div().text_sm().child(title)
tab_title(avatar, label)
}
}
@@ -16,7 +16,7 @@ use gpui_component::{
use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
use utils::relative_time;
pub(super) mod detail;
@@ -27,6 +27,7 @@ use self::new::open_new_pull_panel;
use super::send_patch::open_send_patch_panel;
use super::status_list::{StatusCounts, filter_by_status};
use crate::views::repo::RepoAction;
use crate::views::{repo_tab_avatar, tab_title};
const ROW_HEIGHT: f32 = 73.;
@@ -202,7 +203,7 @@ impl PullRequestsView {
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(Avatar::new(author.clone()).picture(picture))
.child(div().child(author)),
)
.child(SharedString::from("opened"))
@@ -333,11 +334,11 @@ impl BasePanel for PullRequestsView {
}
impl Panel for PullRequestsView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!(
"{}/pull-requests",
self.repo_name
)))
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = repo_tab_avatar(self.store.read(cx), cx);
let label = SharedString::from(format!("{}/pulls", self.repo_name));
tab_title(avatar, label)
}
}
@@ -32,6 +32,7 @@ use signed_state::{
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
use crate::views::{repo_tab_avatar, tab_title};
pub struct NewPullRequestView {
focus_handle: FocusHandle,
@@ -924,6 +925,7 @@ impl NewPullRequestView {
let panel = cx.new(|cx| {
CommitDiffView::new(
self.store.clone(),
repo_path,
self.repo_name.clone(),
commit_id.into(),
@@ -1323,11 +1325,11 @@ impl BasePanel for NewPullRequestView {
}
impl Panel for NewPullRequestView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!(
"{}/new-pull-request",
self.repo_name
)))
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = repo_tab_avatar(self.store.read(cx), cx);
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
tab_title(avatar, label)
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
use nostr::prelude::PublicKey;
use signed_core::Announcement;
use signed_state::ProfileStore;
use signed_ui::{UserAvatar, middle_truncate};
use signed_ui::{Avatar, middle_truncate};
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| {
@@ -173,7 +173,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
.gap_2()
.items_center()
.min_w_0()
.child(UserAvatar::new(name.clone()).picture(picture))
.child(Avatar::new(name.clone()).picture(picture))
.child(
div()
.flex_1()
+4 -2
View File
@@ -106,8 +106,10 @@ impl RepoHistoryView {
// Same display name as the repo detail panel's title.
let repo_name = repo_display_name(self.store.read(cx));
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
let store = self.store.clone();
let panel = cx.new(|cx| {
CommitDiffView::new(store, worktree, repo_name, commit_id.into(), window, cx)
});
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
+9 -4
View File
@@ -31,7 +31,7 @@ use signed_state::{
pr_proposes_checkout,
};
use signed_ui::{
CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate,
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate,
ref_selector_trigger,
};
@@ -55,6 +55,7 @@ use crate::views::pull_requests::new::open_new_pull_panel;
use crate::views::repo::about::open_about_dialog;
use crate::views::send_patch::open_send_patch_panel;
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
use crate::views::{repo_tab_avatar, tab_title};
#[derive(Clone, Copy, PartialEq, Eq)]
enum RefKind {
@@ -1490,7 +1491,7 @@ impl RepoDetailView {
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(owner_name.clone()).picture(owner_picture))
.child(Avatar::new(owner_name.clone()).picture(owner_picture))
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
)
.when(!rest.is_empty(), |this| {
@@ -2031,7 +2032,7 @@ fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
h_flex()
.gap_1()
.text_color(cx.theme().foreground)
.child(UserAvatar::new(profile.name()).picture(profile.picture()))
.child(Avatar::new(profile.name()).picture(profile.picture()))
.child(profile.name()),
)
})
@@ -2054,7 +2055,11 @@ impl BasePanel for RepoDetailView {
impl Panel for RepoDetailView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
repo_display_name(self.store.read(cx))
let store = self.store.read(cx);
let name = repo_display_name(store);
let avatar = repo_tab_avatar(store, cx);
tab_title(avatar, name)
}
}
+4 -4
View File
@@ -16,10 +16,10 @@ use gpui_component::{
};
use signed_core::Announcement;
use signed_state::{ProfileStore, RepoListStore, Timestamp};
use signed_ui::{SegmentButton, UserAvatar};
use signed_ui::{Avatar, SegmentButton};
use utils::relative_time;
use super::open_repo_panel;
use super::{open_repo_panel, panel_avatar, tab_title};
const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
@@ -289,7 +289,7 @@ impl RepoListView {
h_flex()
.gap_2()
.items_center()
.child(UserAvatar::new(owner.name()).picture(owner.picture()))
.child(Avatar::new(owner.name()).picture(owner.picture()))
.child(
div()
.text_xs()
@@ -389,7 +389,7 @@ impl BasePanel for RepoListView {
impl Panel for RepoListView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from("Explore"))
tab_title(panel_avatar("explore"), SharedString::from("Explore"))
}
}
+7 -2
View File
@@ -11,6 +11,8 @@ use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use signed_state::RepoStore;
use crate::views::{repo_tab_avatar, tab_title};
pub struct SendPatchView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
@@ -205,8 +207,11 @@ impl BasePanel for SendPatchView {
}
impl Panel for SendPatchView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!("{}/send-patch", self.repo_name)))
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = repo_tab_avatar(self.store.read(cx), cx);
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
tab_title(avatar, label)
}
}
+8 -5
View File
@@ -23,7 +23,7 @@ use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos,
};
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
@@ -447,7 +447,9 @@ impl SidebarPanel {
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = announcement.name().map(SharedString::from);
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
// The sidebar rows are compact, so the avatar matches the 16px nav icons.
let avatar =
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
let announcement = announcement.clone();
let unpushed = self
@@ -552,7 +554,7 @@ impl SidebarPanel {
Button::new("user").text().dropdown_caret(true).child(
h_flex()
.gap_1()
.child(UserAvatar::new(name.clone()).picture(picture))
.child(Avatar::new(name.clone()).picture(picture))
.child(div().text_xs().font_semibold().child(name)),
),
),
@@ -654,7 +656,8 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
let avatar = PixelAvatar::new(entry.path.to_string_lossy());
// Matches the other sidebar rows, and the badge below sits at its corner.
let avatar = PixelAvatar::new(entry.path.to_string_lossy()).xsmall();
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
return avatar.into_any_element();
@@ -668,7 +671,7 @@ fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
.child(avatar)
.child(
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
UserAvatar::new(profile.name())
Avatar::new(profile.name())
.picture(profile.picture())
.size(px(14.)),
),
@@ -18,7 +18,7 @@ use gpui_component::{
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
v_flex,
};
use settings::{AppearanceMode, Settings, SettingsStore};
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
use signed_ui::{SelectOption, setting_block, setting_row};
use super::{normalize_server, server_host};
@@ -52,6 +52,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
/// Created once when the dialog opens, so control state survives re-renders.
struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>,
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>,
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
font_size: Entity<InputState>,
@@ -89,6 +90,23 @@ impl SettingsControls {
)
});
let event_fetching_options = vec![
SelectOption::new("curated", "Curated"),
SelectOption::new("uncensored", "Uncensored"),
];
let event_fetching_value = match settings.event_fetching {
EventFetchingStrategy::Curated => "curated",
EventFetchingStrategy::Uncensored => "uncensored",
};
let event_fetching = cx.new(|cx| {
SelectState::new(
event_fetching_options.clone(),
selected_index(&event_fetching_options, event_fetching_value),
window,
cx,
)
});
let (light_options, dark_options) = theme_options(cx);
let light_theme = cx.new(|cx| {
SelectState::new(
@@ -147,6 +165,19 @@ impl SettingsControls {
}
}));
subscriptions.push(cx.subscribe(&event_fetching, |_, event, cx| {
if let SelectEvent::Confirm(Some(value)) = event {
let strategy = match value.as_ref() {
"curated" => EventFetchingStrategy::Curated,
_ => EventFetchingStrategy::Uncensored,
};
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.event_fetching = strategy, cx);
});
}
}));
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
if let SelectEvent::Confirm(Some(value)) = event {
let store = SettingsStore::global(cx);
@@ -252,6 +283,7 @@ impl SettingsControls {
Self {
appearance,
event_fetching,
light_theme,
dark_theme,
font_size,
@@ -290,8 +322,12 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
.child(Separator::horizontal())
.child(theme_section(&settings, controls, cx))
.child(Separator::horizontal())
.child(tab_bar_section(&settings, cx))
.child(Separator::horizontal())
.child(grasp_servers_section(&settings, controls, cx))
.child(Separator::horizontal())
.child(event_fetching_section(controls, cx))
.child(Separator::horizontal())
.child(repositories_section(&settings, controls, cx))
}
@@ -304,6 +340,16 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
))
}
/// Which relays repository activity is fetched from.
fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex().w_full().gap_3().child(setting_row(
cx,
"Event Fetching Strategy",
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
Select::new(&controls.event_fetching).w_full(),
))
}
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex()
.gap_3()
@@ -374,6 +420,26 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
))
}
/// Tab bar chrome, such as its next/previous tab buttons.
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
v_flex().w_full().gap_3().child(setting_row(
cx,
"Hide Tab Navigation Buttons",
"Enable to show navigation buttons when more than 10 tabs.",
Switch::new("hide-tab-navigation")
.checked(settings.tab_bar.hide_navigation_buttons)
.on_click(move |checked: &bool, _window, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(
|settings| settings.tab_bar.hide_navigation_buttons = *checked,
cx,
);
});
}),
))
}
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
fn grasp_servers_section(
settings: &Settings,