This commit is contained in:
2026-09-03 16:38:52 +07:00
parent 018395d0c5
commit 212f35d6bb
69 changed files with 2130 additions and 2373 deletions
+3 -3
View File
@@ -151,14 +151,14 @@ mod tests {
assert_eq!(theme.border, parse("#27272A")); // neutral-800 assert_eq!(theme.border, parse("#27272A")); // neutral-800
assert_eq!(theme.green, parse("#22C55E")); // green-500 assert_eq!(theme.green, parse("#22C55E")); // green-500
} else { } else {
// Light theme chrome is neutral; lime is a brand accent only. // Light theme chrome is neutral, lime is a brand accent only.
assert_eq!(theme.background, parse("#FFFFFF")); assert_eq!(theme.background, parse("#FFFFFF"));
assert_eq!(theme.foreground, parse("#18181B")); assert_eq!(theme.foreground, parse("#18181B"));
assert_eq!(theme.border, parse("#E4E4E7")); assert_eq!(theme.border, parse("#E4E4E7"));
assert_eq!(theme.green, parse("#16A34A")); assert_eq!(theme.green, parse("#16A34A"));
} }
// Active tab: a paler lime on light, a dim moss on dark — each // Active tab, a paler lime on light and a dim moss on dark.
// paired with readable, contrasting text. // Each is paired with readable contrasting text.
if config.mode.is_dark() { if config.mode.is_dark() {
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
+18 -34
View File
@@ -1,7 +1,3 @@
//! The dock-area appearance: the outer frame, the split frames, and one
//! dock's chrome. Ported from the vendored dock's `DockArea`/`Dock` render
//! onto `gpui_base::dock::DockAreaRenderer`.
use std::cell::Cell; use std::cell::Cell;
use std::ops::Deref as _; use std::ops::Deref as _;
use std::rc::Rc; use std::rc::Rc;
@@ -26,8 +22,7 @@ use crate::tab_panel::SignedTabGroupSkin;
use crate::tiles::SignedTilesSkin; use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle}; use crate::{TAB_BAR_HEIGHT, panel_handle};
/// What every part of the skin reads, and the dock area it belongs to. /// State the skin shares with its per-container renderers.
/// Shared by reference with the per-container renderers.
pub(crate) struct SkinShared { pub(crate) struct SkinShared {
area: WeakEntity<DockArea>, area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>, toggle_button_visible: Cell<bool>,
@@ -53,16 +48,14 @@ impl SkinShared {
&self.resizing_dock &self.resizing_dock
} }
/// Redraw the area after a setting changed. The skin is not an entity, so /// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
/// nothing else would notice.
pub(crate) fn notify(&self, cx: &mut App) { pub(crate) fn notify(&self, cx: &mut App) {
_ = self.area.update(cx, |_, cx| cx.notify()); _ = self.area.update(cx, |_, cx| cx.notify());
} }
} }
/// The Signed appearance for a [`DockArea`]. /// The Signed appearance for a [`DockArea`].
/// /// Install it in the constructor, the only place the area's weak handle is available.
/// Install it at construction, where the area's own weak handle is available:
/// ///
/// ```ignore /// ```ignore
/// let dock = cx.new(|cx| { /// let dock = cx.new(|cx| {
@@ -90,8 +83,7 @@ impl SignedDockSkin {
&self.shared &self.shared
} }
/// Whether tab bars offer the affordance that collapses a neighbouring /// Whether tab bars offer the affordance that collapses a neighbouring dock.
/// dock.
pub fn is_toggle_button_visible(&self) -> bool { pub fn is_toggle_button_visible(&self) -> bool {
self.shared.is_toggle_button_visible() self.shared.is_toggle_button_visible()
} }
@@ -112,8 +104,8 @@ impl SignedDockSkin {
} }
} }
/// The payload a dock's resize handle drags. It draws nothing: the handle /// Payload a dock's resize handle drags.
/// itself is the affordance. /// It draws nothing, the handle element is the visible affordance.
#[derive(Clone)] #[derive(Clone)]
struct ResizePanel; struct ResizePanel;
@@ -144,9 +136,7 @@ impl DockAreaRenderer for SignedDockSkin {
} }
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> { fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
// `size_full` is what the old `StackPanel::render` carried; `flex_1` // `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
// is belt and braces so the frame never collapses to zero height in
// an unsizing parent.
div() div()
.id(("dock-split-frame", node.as_u64())) .id(("dock-split-frame", node.as_u64()))
.size_full() .size_full()
@@ -166,8 +156,8 @@ impl DockAreaRenderer for SignedDockSkin {
let placement = dock.placement(); let placement = dock.placement();
let open = dock.is_open(); let open = dock.is_open();
// A closed left or right dock takes no space at all; a closed bottom // A closed left or right dock takes no space.
// dock keeps a strip so its tab bar stays clickable. // A closed bottom dock keeps a strip so its tab bar stays clickable.
if !open && !placement.is_bottom() { if !open && !placement.is_bottom() {
return div().into_any_element(); return div().into_any_element();
} }
@@ -183,8 +173,7 @@ impl DockAreaRenderer for SignedDockSkin {
// Base never builds a dock for the centre. // Base never builds a dock for the centre.
DockPlacement::Center => this, DockPlacement::Center => this,
}) })
// The closed bottom dock's strip is the tab bar itself, which is // The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
// a full tab bar tall.
.when(!open && placement.is_bottom(), |this| { .when(!open && placement.is_bottom(), |this| {
this.h(TAB_BAR_HEIGHT) this.h(TAB_BAR_HEIGHT)
}) })
@@ -197,9 +186,8 @@ impl DockAreaRenderer for SignedDockSkin {
.into_any_element() .into_any_element()
} }
/// The "unknown panel" message the old `InvalidPanel` drew. It answers /// Placeholder for a panel this build cannot construct.
/// `dump` with the state it was handed, so a layout written by a build /// It dumps the state it was handed, so the layout survives a load and save.
/// that knows the panel survives a load and save here.
fn build_placeholder( fn build_placeholder(
&self, &self,
state: &PanelState, state: &PanelState,
@@ -241,10 +229,8 @@ impl SignedDockSkin {
} }
} }
/// Turns the window's mouse stream into dock resizing. A resize is driven /// Turns the window's mouse stream into dock resizing.
/// by pointer moves anywhere in the window, so this paints nothing and /// It draws nothing, the `paint` hook is the only window listener registration point.
/// exists for its `paint` hook — the only place a window-level mouse
/// listener can be registered.
struct DockResizeTracker { struct DockResizeTracker {
dock: DockContext, dock: DockContext,
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
@@ -310,10 +296,8 @@ impl Element for DockResizeTracker {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return; return;
} }
// Dragging a closed dock's handle reopens it. The live // Dragging a closed dock's handle reopens it.
// state is read rather than the render-time snapshot in // Read the live state, the snapshot in `dock` would toggle it shut again.
// `dock`, which would still say closed for the rest of the
// frame and toggle it shut again on the next move.
let open = shared let open = shared
.area() .area()
.upgrade() .upgrade()
@@ -332,8 +316,8 @@ impl Element for DockResizeTracker {
return; return;
} }
shared.resizing_dock().set(None); shared.resizing_dock().set(None);
// The size lives on the dock, not in the layout tree, so // The size lives on the dock, not the layout tree.
// nothing else tells a subscriber to persist it. // Nothing else tells a subscriber to persist it.
_ = shared _ = shared
.area() .area()
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
+2 -4
View File
@@ -7,10 +7,8 @@ use gpui_component::ActiveTheme as _;
use crate::Panel; use crate::Panel;
/// Stands in for a panel this build cannot construct. It reports the /// Stands in for a panel this build cannot construct.
/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so /// It returns the state it was handed, so the layout survives a load and save.
/// a layout written by a build that knows the panel survives a load and
/// save here.
pub(crate) struct InvalidPanel { pub(crate) struct InvalidPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
+1 -13
View File
@@ -1,14 +1,3 @@
//! The Signed dock skin.
//!
//! The dock engine lives upstream (`gpui_base::dock` owns the layout tree,
//! drags, zoom and persistence); this crate is the appearance the app used
//! to vendor from gpui-component — a 44px tab bar that doubles as the
//! window title bar, with pill tabs, window controls, title-bar dragging
//! and previous/next tab buttons.
//!
//! Everything `gpui_component::dock` exports is re-exported here, so the app
//! keeps importing the dock from a single place.
use gpui::{Pixels, px}; use gpui::{Pixels, px};
mod dock_area; mod dock_area;
@@ -28,8 +17,7 @@ pub use gpui_component::dock::{
/// The fixed height of the tab bar, which doubles as the window title bar. /// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.); pub const TAB_BAR_HEIGHT: Pixels = px(44.);
/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the /// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
/// same `Dock.*` keys resolved to English so the crate has no i18n dependency.
pub(crate) fn t(key: &'static str) -> &'static str { pub(crate) fn t(key: &'static str) -> &'static str {
match key { match key {
"Dock.Unnamed" => "Unnamed", "Dock.Unnamed" => "Unnamed",
+57 -109
View File
@@ -1,13 +1,3 @@
//! The Signed appearance for a tab group.
//!
//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
//! visible is here, ported from the vendored dock: the pill tab bar that
//! doubles as the window title bar (with window controls, title-bar
//! dragging, and previous/next tab buttons), the toolbar, the ellipsis menu,
//! the dock collapse affordance, the drop placeholder, and the styled drag
//! preview.
use std::cell::Cell; use std::cell::Cell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@@ -38,12 +28,10 @@ use crate::{
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls, ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls,
}; };
/// The size the styled drag preview occupies, reported to base so a drop /// The drag preview's size, reported to base for the drop placeholder.
/// placeholder knows where to fly in from.
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.)); const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
/// A panel's title, or its registered name when it reached base without this /// A panel's title, or its registered name when the panel has no handle.
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
pub(crate) fn panel_title( pub(crate) fn panel_title(
panel: &Arc<dyn BasePanelView>, panel: &Arc<dyn BasePanelView>,
window: &mut Window, window: &mut Window,
@@ -56,9 +44,7 @@ pub(crate) fn panel_title(
} }
/// The preview that follows the cursor while a panel is dragged. /// 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.
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
/// appearance half, reintroduced here.
struct DragPanelPreview { struct DragPanelPreview {
panel: Arc<dyn BasePanelView>, panel: Arc<dyn BasePanelView>,
} }
@@ -83,10 +69,8 @@ impl Render for DragPanelPreview {
} }
} }
/// Where the zoom affordance goes for the group's displayed panel, or `None` /// The zoom affordance for the group's displayed panel, if it offers one.
/// when there is none to offer. Both [`Panel::zoom_control`] (where) and /// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
/// zoom that fails the latter.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> { fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?; let panel = group.active_panel()?;
panel panel
@@ -95,8 +79,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
.flatten() .flatten()
} }
/// The left-most, top-most tab group in a container — where a left dock's /// The left-most, top-most tab group in a container.
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`. /// A left dock's collapse button lives in this group.
fn left_top_group(node: &PaneNode) -> Option<NodeId> { fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() { match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()), PaneRef::Tabs { .. } => Some(node.id()),
@@ -105,9 +89,8 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
} }
} }
/// The right-most, top-most tab group. A vertical split stacks its children, /// The right-most, top-most tab group.
/// so its *first* child is the top one; a horizontal split's last child is /// A vertical split picks its first child, a horizontal split picks its last.
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
fn right_top_group(node: &PaneNode) -> Option<NodeId> { fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() { match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()), PaneRef::Tabs { .. } => Some(node.id()),
@@ -120,23 +103,17 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
} }
} }
/// One tab group's appearance. Built once per container, so the tab bar's /// One tab group's appearance, built once per container so its geometry is its own.
/// scroll position and measured title-bar geometry belong to the group.
pub(crate) struct SignedTabGroupSkin { pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
/// The displayed tab the last frame drew, so a change scrolls the new tab /// The tab shown last frame, so a change scrolls the new one into view.
/// into view.
last_active_ix: Cell<Option<usize>>, last_active_ix: Cell<Option<usize>>,
/// Bounds of the title bar row (the wrapper around the tab bar), in /// Bounds of the title bar row, measured to place the title-bar drag overlay.
/// window coordinates. Measured via `on_prepaint` to position the
/// title-bar drag overlay.
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's trailing empty space (right after the last /// Bounds of the empty strip after the last tab, where the drag region starts.
/// tab), which marks where the draggable region starts.
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the /// Bounds of the suffix area, where the drag region ends.
/// draggable region ends.
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
} }
@@ -152,9 +129,8 @@ impl SignedTabGroupSkin {
} }
} }
/// A group that is the left dock's whole content with a single panel /// A group that is the left dock's only group, with one panel, draws no chrome.
/// draws no chrome at all — the vendored dock rendered such a panel bare, /// The vendored dock rendered such a panel bare and the sidebar is one.
/// and the sidebar is one.
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool { fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
let Some(area) = self.shared.area().upgrade() else { let Some(area) = self.shared.area().upgrade() else {
return false; return false;
@@ -169,11 +145,8 @@ impl SignedTabGroupSkin {
left == group.node() && group.panels().len() == 1 left == group.node() && group.panels().len() == 1
} }
/// The bottom or right dock whose root tab group this group is, if any. /// 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.
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied — but a bottom/right panel is supposed to be
/// closable, so the skin routes around the bar for these groups.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> { fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?; let area = self.shared.area().upgrade()?;
let area = area.read(cx); let area = area.read(cx);
@@ -185,10 +158,8 @@ impl SignedTabGroupSkin {
}) })
} }
/// The drag payload for the tab at `ix`, or `None` when this group must /// The tab's drag payload, or `None` when the group must not be rearranged.
/// not be rearranged. A locked group is never draggable; a group that is /// A locked group never is, a bottom or right dock root always is.
/// a bottom/right dock's only content still is, because the center is
/// always there to land in.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> { fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() { if group.is_locked() {
return None; return None;
@@ -199,8 +170,8 @@ impl SignedTabGroupSkin {
group.drag_panel(ix, cx) group.drag_panel(ix, cx)
} }
/// Whether a dock's collapse affordance belongs in *this* group's tab /// A dock's collapse button for this group's bar, or `None` when it does not belong.
/// bar, and which way it points. `None` means this group draws none. /// The icon direction depends on whether the dock is open.
fn dock_toggle_button( fn dock_toggle_button(
&self, &self,
placement: DockPlacement, placement: DockPlacement,
@@ -213,8 +184,7 @@ impl SignedTabGroupSkin {
let area = self.shared.area().upgrade()?; let area = self.shared.area().upgrade()?;
let area = area.read(cx); let area = area.read(cx);
// A dock that does not exist is not collapsible, so this covers the // A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
// old `left_dock.is_some()` test too.
if !area.is_dock_collapsible(placement) { if !area.is_dock_collapsible(placement) {
return None; return None;
} }
@@ -263,8 +233,8 @@ impl SignedTabGroupSkin {
) )
} }
/// The previous/next tab buttons shown in the tab bar's leading prefix. /// The previous and next tab buttons in the tab bar's leading prefix.
/// Always rendered, disabled at the ends of the strip (or collapsed). /// Always rendered, disabled at the strip ends or when collapsed.
fn render_prev_next_tab_buttons( fn render_prev_next_tab_buttons(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -306,8 +276,7 @@ impl SignedTabGroupSkin {
) )
} }
/// The trailing controls: the panel's own buttons, the zoom affordance, /// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
/// and the ellipsis menu.
fn render_toolbar( fn render_toolbar(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -323,9 +292,8 @@ impl SignedTabGroupSkin {
let control = zoom_control(group, cx); let control = zoom_control(group, cx);
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible()); let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = control.is_some_and(|control| control.menu_visible()); let menu_zoom = control.is_some_and(|control| control.menu_visible());
// A bottom/right dock's only panel cannot be closed through the // A bottom or right dock's only panel cannot close through the group.
// group (base keeps a dock's last group), but the skin handles that // The close item is offered, the skin removes the whole dock instead.
// close by removing the whole dock, so the item is offered.
let closable = group.is_closable() let closable = group.is_closable()
|| (self.is_dock_root_group(group, cx).is_some() || (self.is_dock_root_group(group, cx).is_some()
&& group.active_panel().is_some_and(|panel| panel.closable(cx))); && group.active_panel().is_some_and(|panel| panel.closable(cx)));
@@ -397,9 +365,9 @@ impl SignedTabGroupSkin {
) )
} }
/// One tab of the pill strip. While collapsed, tabs lose the active /// One tab of the pill strip.
/// style and all interactions, and the strip becomes the way a closed /// While collapsed, tabs lose the active style and all interactions.
/// bottom dock is opened again. /// The strip is also how a closed bottom dock is opened again.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_tab( fn render_tab(
&self, &self,
@@ -433,8 +401,7 @@ impl SignedTabGroupSkin {
Some(tab_name) => this.child(tab_name), Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)), None => this.child(panel_title(&panel, window, cx)),
}) })
// Pill presentation: the selected tab is the filled pill, the // Pill style, the selected tab is the filled pill, others show only on hover.
// rest are transparent until hovered.
.styles(|styles| { .styles(|styles| {
styles.selected(|style| { styles.selected(|style| {
style style
@@ -457,8 +424,7 @@ impl SignedTabGroupSkin {
move |_, window, cx| { move |_, window, cx| {
group.select_tab(ix, window, cx); group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock is how it // Clicking the strip of a collapsed bottom dock reopens it.
// is opened again.
if is_bottom_dock && collapsed { if is_bottom_dock && collapsed {
_ = area.update(cx, |area, cx| { _ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, cx); area.toggle_dock(DockPlacement::Bottom, window, cx);
@@ -509,9 +475,8 @@ impl SignedTabGroupSkin {
}) })
} }
/// The strip after the last tab: a drop target for panels and host-owned /// The strip after the last tab, a drop target for panels and other drag items.
/// drag items. Its left edge (right after the last tab) marks the start /// Its left edge marks where the title-bar drag overlay starts.
/// of the title-bar drag overlay.
fn render_empty_space( fn render_empty_space(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -541,9 +506,8 @@ impl SignedTabGroupSkin {
let group = group.clone(); let group = group.clone();
let node = group.node(); let node = group.node();
move |drag: &DragPanel, window, cx| { move |drag: &DragPanel, window, cx| {
// A panel dropped past its own last tab lands in the // A panel dropped past its own last tab lands in the final slot.
// final slot; one from elsewhere is appended in the // A panel from elsewhere is appended in the background.
// background.
let ix = (drag.source() == node).then(|| tabs_count - 1); let ix = (drag.source() == node).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx); group.drop_panel(drag.clone(), ix, false, window, cx);
} }
@@ -564,37 +528,30 @@ impl SignedTabGroupSkin {
impl TabGroupRenderer for SignedTabGroupSkin { impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> { fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx); let control = zoom_control(group, cx);
// An emptied group — its last panel was dragged away — draws nothing, // An emptied group draws nothing, so no bare tab bar is left behind.
// so an emptied dock does not leave a bare tab bar behind.
if group.panels().is_empty() { if group.panels().is_empty() {
return div().id("tab-panel"); return div().id("tab-panel");
} }
// Closing the only panel of a bottom/right dock would leave an // Base refuses an empty dock, so closing its only panel removes the dock.
// empty dock, which base refuses; the skin removes the dock instead.
let dock_to_remove = (group.panels().len() <= 1) let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx)) .then(|| self.is_dock_root_group(group, cx))
.flatten(); .flatten();
let shared = self.shared.clone(); let shared = self.shared.clone();
// `v_flex`, not `div`: gpui's default display is Block, and in block // `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
// layout a child's `flex_grow` is ignored — the content region below
// the tab bar would resolve to zero height.
v_flex() v_flex()
.id("tab-panel") .id("tab-panel")
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.bg(cx.theme().tokens.background) .bg(cx.theme().tokens.background)
// A collapsed group is a strip of tabs with no content, and the // A collapsed group has no content, so these actions are not registered.
// actions act on content.
.when(!group.is_collapsed(), |this| { .when(!group.is_collapsed(), |this| {
this.on_action({ this.on_action({
let group = group.clone(); let group = group.clone();
move |_: &ToggleZoom, window, cx| { move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel // A panel with no zoom control is not zoomed in by the keybinding.
// offering none is not zoomed *in* by the keybinding // Zooming out is never refused.
// either. Zooming out is never refused: a panel that // Otherwise a zoomed panel that lost its control would strand the user.
// stopped offering the control while zoomed would
// otherwise strand the user with no way back.
if !group.is_zoomed() && control.is_none() { if !group.is_zoomed() && control.is_none() {
return; return;
} }
@@ -628,8 +585,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> { fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
v_flex() v_flex()
.id("active-panel") .id("active-panel")
// A collapsed group draws its tab strip and nothing else, so the // A collapsed group draws its tab strip only, so the content claims no space.
// content region must not claim any space.
.when(!group.is_collapsed(), |this| this.flex_1()) .when(!group.is_collapsed(), |this| this.flex_1())
} }
@@ -639,14 +595,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> AnyElement {
// An emptied group draws no tab bar; the app prunes the emptied // An emptied group draws no tab bar, the app prunes the emptied dock later.
// bottom/right dock a moment later.
if group.panels().is_empty() { if group.panels().is_empty() {
return Empty.into_any_element(); return Empty.into_any_element();
} }
// The sidebar group draws no chrome at all, like the vendored dock's // The sidebar group draws no chrome, like the vendored `DockItem::Panel`.
// bare `DockItem::Panel`.
if self.is_plain_sidebar_group(group, cx) { if self.is_plain_sidebar_group(group, cx) {
return Empty.into_any_element(); return Empty.into_any_element();
} }
@@ -660,10 +614,9 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some(); let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. // On macOS the traffic lights overlay the window's top-left corner.
// Only the group whose tab bar actually sits under them reserves the // Only the tab bar that sits under them reserves the space.
// space — the center's left-most, top-most group when the left dock // That is the center's top-left group when the left dock is closed or absent.
// is closed or absent.
let needs_traffic_light_padding = cfg!(target_os = "macos") let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| { && self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx); let area = area.read(cx);
@@ -674,8 +627,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
== Some(group.node()) == Some(group.node())
}); });
// Bring a newly displayed tab into view. The group owns selection // Bring a newly displayed tab into view.
// now, so the skin notices the change rather than being told about it. // The group owns selection, so the skin watches for the change itself.
let displayed = group.active_panel().map(|panel| panel.panel_id(cx)); let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let visible: Vec<usize> = group let visible: Vec<usize> = group
.panels() .panels()
@@ -690,10 +643,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
self.scroll_handle.scroll_to_item(visible_ix); self.scroll_handle.scroll_to_item(visible_ix);
} }
// The tab strip lays out at content width, so the area after the // The tab strip ends at the last tab, the bar has no element after it.
// last tab has no element. Cover that dead zone (last tab's right // Cover that dead zone with an overlay so it can drag the window.
// edge to suffix's left edge) with a measured overlay so the whole
// non-interactive area can drag the window.
let drag_overlay = match ( let drag_overlay = match (
self.title_bar_bounds.get(), self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(), self.title_bar_strip_bounds.get(),
@@ -728,8 +679,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if !panel.visible(cx) { if !panel.visible(cx) {
return None; return None;
} }
// A collapsed group shows no tab as active: the strip is a // Collapsed tabs never show as active, the strip only reopens the dock.
// way back in, not a selection.
if collapsed { if collapsed {
active = false; active = false;
} }
@@ -768,7 +718,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
h_flex() h_flex()
.items_center() .items_center()
.top_0() .top_0()
// Right -1 for avoid border overlap with the first tab // -1 px so the border does not overlap the first tab.
.right(-px(1.)) .right(-px(1.))
.h_full() .h_full()
.gap_2() .gap_2()
@@ -852,9 +802,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
cx: &mut App, cx: &mut App,
) -> Option<AnyElement> { ) -> Option<AnyElement> {
let (from, to) = (indicator.from(), indicator.to()); let (from, to) = (indicator.from(), indicator.to());
// The placeholder animates from wherever it was to where the drop // The element sits at the drop target, the animation walks back from the source.
// would land, so its own element is positioned at the destination and
// the animation only has to walk the difference back to zero.
let offset = from.origin() - to.origin(); let offset = from.origin() - to.origin();
Some( Some(
+14 -37
View File
@@ -1,11 +1,3 @@
//! The Signed appearance for a tiles canvas.
//!
//! `gpui_base::dock::TilesState` owns the geometry — snapping, the resize
//! arithmetic, the undo stack, the zoom flag — and draws none of it. The tile
//! frame, its title bar and its resize affordances are here, ported from
//! gpui-component's `TilesSkin` (the vendored dock had no tiles canvas, so
//! there is no local look to preserve).
use std::rc::Rc; use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
@@ -52,9 +44,7 @@ impl Render for DragResizing {
} }
/// One tiles canvas's appearance. /// One tiles canvas's appearance.
/// /// Built once per container, so its scroll position belongs to the canvas it scrolls.
/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per
/// container — so the scroll position belongs to the canvas it scrolls.
pub(crate) struct SignedTilesSkin { pub(crate) struct SignedTilesSkin {
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
@@ -101,11 +91,8 @@ impl SignedTilesSkin {
}) })
} }
/// The trailing controls of a tile's title bar: zoom, close and the /// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
/// ellipsis menu. They use click handlers rather than the /// They use click handlers, the zoom and close actions target a focused tab group.
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
/// actions, which are dispatched to a focused tab group — a tile is
/// not one.
fn render_tile_controls( fn render_tile_controls(
&self, &self,
tile: &TileContext, tile: &TileContext,
@@ -213,21 +200,17 @@ impl TilesRenderer for SignedTilesSkin {
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().tile_radius) .rounded(cx.theme().tile_radius)
// Room for the title bar, which is positioned over the padding so // Room for the title bar, which overlays the top padding.
// the panel below it is never covered. Base draws the panel view // Base draws the panel as a plain child, this keeps them apart.
// as a plain child, so this is the only way to keep the two from
// overlapping.
.pt(DRAG_BAR_HEIGHT) .pt(DRAG_BAR_HEIGHT)
// Base installs the stored bounds on an ordinary tile and nothing // Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
// at all on a zoomed one — how a zoomed tile fills the dock is
// this skin's decision.
.when(tile.is_zoomed(), |this| this.size_full()) .when(tile.is_zoomed(), |this| this.size_full())
.on_mouse_down(MouseButton::Left, { .on_mouse_down(MouseButton::Left, {
let tile = tile.clone(); let tile = tile.clone();
move |_, window, cx| tile.bring_to_front(window, cx) move |_, window, cx| tile.bring_to_front(window, cx)
}) })
// A gesture can end with the pointer anywhere, so both halves are // A gesture can end anywhere, so both mouse-up hooks run.
// needed; each is a no-op unless this tile is the one moving. // Each is a no-op unless this tile is the one that moved.
.on_mouse_up(MouseButton::Left, { .on_mouse_up(MouseButton::Left, {
let tile = tile.clone(); let tile = tile.clone();
move |_, window, cx| { move |_, window, cx| {
@@ -274,8 +257,7 @@ impl TilesRenderer for SignedTilesSkin {
) )
.children(handle.and_then(|handle| handle.title_suffix(window, cx))) .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
.child(self.render_tile_controls(tile, window, cx)) .child(self.render_tile_controls(tile, window, cx))
// A zoomed tile is not at its stored bounds, so there is nothing // A zoomed tile is not at its stored bounds, so moving it would mean nothing.
// for a move to mean; base refuses the gesture too.
.when(!tile.is_zoomed(), |this| { .when(!tile.is_zoomed(), |this| {
this.cursor_grab() this.cursor_grab()
.on_mouse_down(MouseButton::Left, { .on_mouse_down(MouseButton::Left, {
@@ -309,10 +291,8 @@ impl TilesRenderer for SignedTilesSkin {
) -> AnyElement { ) -> AnyElement {
let bounds = tile.bounds(); let bounds = tile.bounds();
// A passive full-tile box so each handle is positioned against the // A passive full-tile box, so handles sit against the tile, not its flow neighbours.
// tile rather than against whatever the flow put it next to. It // It registers no interaction, so it does not shadow the panel underneath.
// registers no interaction of its own, so it does not shadow the panel
// underneath.
div() div()
.absolute() .absolute()
.top_0() .top_0()
@@ -376,9 +356,7 @@ impl TilesRenderer for SignedTilesSkin {
.into_any_element() .into_any_element()
} }
/// The panel of a tile gets `size_full` here; base draws the panel as a /// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
/// plain child, so without it a panel that does not size itself has no
/// size.
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> { fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
h_flex() h_flex()
.id(("tile-panel", tile.panel_id().as_u64())) .id(("tile-panel", tile.panel_id().as_u64()))
@@ -386,9 +364,8 @@ impl TilesRenderer for SignedTilesSkin {
.size_full() .size_full()
} }
/// The canvas scrollbar. It must be an overlay: the frame is the scroll /// The canvas scrollbar, as an overlay.
/// container and base appends the tiles after it, so a scrollbar placed /// Placed inside the frame it would end up underneath every tile.
/// inside would paint and hit-test underneath every tile.
fn render_overlay( fn render_overlay(
&self, &self,
content: Size<Pixels>, content: Size<Pixels>,
+1 -2
View File
@@ -147,8 +147,7 @@ pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoEle
.items_center() .items_center()
.flex_shrink_0() .flex_shrink_0()
.h_full() .h_full()
// Like native windows apps, the controls span the title bar but never // The controls span the title bar but never grow past the tab bar height.
// grow past the tab bar height.
.when(cfg!(target_os = "windows"), |this| { .when(cfg!(target_os = "windows"), |this| {
this.max_h(TAB_BAR_HEIGHT) this.max_h(TAB_BAR_HEIGHT)
}) })
+2 -9
View File
@@ -1,8 +1,3 @@
//! Render-path smoke tests: the skin reads the dock area while rendering, and
//! GPUI panics if an entity is read while it is leased (being updated). These
//! pin that the first frame — docks, groups, tab bars — renders without
//! tripping the lease check.
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle}; use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
use gpui::{ use gpui::{
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render, App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
@@ -84,12 +79,10 @@ fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
}); });
}); });
// The first frame walks every render hook — the dock frame, each group's // The first frame walks every render hook, all of which read the dock area.
// tab bar, the toolbar — all of which read the dock area.
cx.update(|window, cx| window.draw(cx).clear(cx)); cx.update(|window, cx| window.draw(cx).clear(cx));
// Emptying a dock leaves an empty group behind; its render must also be // Emptying a dock leaves an empty group, its render must also be safe.
// safe (and draw nothing).
cx.update(|window, cx| { cx.update(|window, cx| {
area.update(cx, |area, cx| { area.update(cx, |area, cx| {
area.remove_panel(bottom, window, cx); area.remove_panel(bottom, window, cx);
+15 -23
View File
@@ -1,18 +1,12 @@
//! Paths to locations used by Signed.
//!
//! Follows the same pattern as Zed's `paths` crate: platform-correct base
//! directories, resolved once and cached, with an optional custom data dir
//! override for portable/dev installs.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;
/// The application name, used to derive platform-specific data, config and /// The application name.
/// cache directory paths. /// It derives the platform-specific data, config and cache directory paths.
pub const APP_NAME: &str = "Signed"; pub const APP_NAME: &str = "Signed";
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on /// Lowercased form of [`APP_NAME`].
/// Linux/FreeBSD and the macOS `~/.config` fallback. /// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
pub const APP_NAME_LOWERCASE: &str = "signed"; pub const APP_NAME_LOWERCASE: &str = "signed";
/// A custom data directory override, set only by [`set_custom_data_dir`]. /// A custom data directory override, set only by [`set_custom_data_dir`].
@@ -35,26 +29,24 @@ pub fn home_dir() -> PathBuf {
dirs::home_dir().expect("failed to determine home directory") dirs::home_dir().expect("failed to determine home directory")
} }
/// Returns the current user's Desktop folder, falling back to the home /// Returns the current user's Desktop folder.
/// directory (or an empty path) when it can't be determined. /// Falls back to the home directory or an empty path when it cannot be determined.
pub fn desktop_dir() -> PathBuf { pub fn desktop_dir() -> PathBuf {
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
} }
/// Returns the current user's Documents folder, falling back to the home /// Returns the current user's Documents folder.
/// directory (or an empty path) when it can't be determined. /// Falls back to the home directory or an empty path when it cannot be determined.
pub fn documents_dir() -> PathBuf { pub fn documents_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
} }
/// Sets a custom directory for all user data, overriding the default data /// Sets a custom directory for all user data, overriding the default data directory.
/// directory. Must be called before any other path operation. The directory /// Must be called before any other path operation.
/// is created if it doesn't exist and canonicalized to an absolute path. /// The directory is created when missing and canonicalized to an absolute path.
///
/// # Panics /// # Panics
/// /// Panics when called after [`data_dir`] or [`config_dir`] was initialized.
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or /// Panics when the directory cannot be created or canonicalized.
/// if the directory cannot be created/canonicalized.
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() { if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
panic!("set_custom_data_dir called after data_dir or config_dir was initialized"); panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
@@ -153,13 +145,13 @@ pub fn logs_dir() -> &'static PathBuf {
}) })
} }
/// Returns the path to the nostr database directory (LMDB). /// Returns the path to the nostr database directory, LMDB.
pub fn nostr_dir() -> &'static PathBuf { pub fn nostr_dir() -> &'static PathBuf {
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new(); static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
NOSTR_DIR.get_or_init(|| data_dir().join("nostr")) NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
} }
/// Returns the path to the local git clone cache (grasp mirrors). /// Returns the path to the local git clone cache, the grasp mirrors.
pub fn repos_dir() -> &'static PathBuf { pub fn repos_dir() -> &'static PathBuf {
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new(); static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
REPOS_DIR.get_or_init(|| data_dir().join("repos")) REPOS_DIR.get_or_init(|| data_dir().join("repos"))
-6
View File
@@ -1,9 +1,3 @@
//! Persisted application settings for Signed.
//!
//! The [`Settings`] model holds the user-configurable values that survive
//! restarts, and [`SettingsStore`] loads them from and saves them to a JSON
//! file on disk (see [`paths::settings_file`]).
mod settings; mod settings;
mod store; mod store;
+16 -22
View File
@@ -2,8 +2,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The default grasp servers offered when the user hasn't published a /// The default grasp servers.
/// grasp list (kind `10317`) yet. /// Offered while the user has not published a grasp list, kind `10317`.
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev", "wss://relay.ngit.dev",
"wss://gitnostr.com", "wss://gitnostr.com",
@@ -14,7 +14,7 @@ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AppearanceMode { pub enum AppearanceMode {
/// Follow the system appearance (light/dark) at runtime. /// Follow the system appearance, light or dark, at runtime.
#[default] #[default]
System, System,
/// Always use the light theme. /// Always use the light theme.
@@ -24,11 +24,9 @@ pub enum AppearanceMode {
} }
/// Theme configuration. /// Theme configuration.
/// /// Fields mirror the gpui-component `Theme` surface customized at startup.
/// The fields mirror the gpui-component `Theme` surface the application /// Applying the settings is then a field-for-field copy.
/// customizes at startup, so applying the settings is a plain field-for-field /// Theme names identify entries in the gpui-component theme registry.
/// copy. The theme names identify entries in the gpui-component theme
/// registry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct ThemeSettings { pub struct ThemeSettings {
@@ -42,7 +40,7 @@ pub struct ThemeSettings {
pub mono_font_size: f32, pub mono_font_size: f32,
/// Corner radius for general elements in pixels. /// Corner radius for general elements in pixels.
pub radius: f32, pub radius: f32,
/// Corner radius for large elements (dialogs, notifications) in pixels. /// Corner radius for large elements, dialogs and notifications, in pixels.
pub radius_lg: f32, pub radius_lg: f32,
/// Whether focused controls draw a ring outside their border. /// Whether focused controls draw a ring outside their border.
pub focus_ring: bool, pub focus_ring: bool,
@@ -69,8 +67,7 @@ impl Default for ThemeSettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct GraspServersSettings { pub struct GraspServersSettings {
/// The servers offered when the user hasn't published a grasp list /// Servers offered while the user has not published a grasp list, kind `10317`.
/// (kind `10317`) yet.
pub default_servers: Vec<String>, pub default_servers: Vec<String>,
} }
@@ -90,7 +87,6 @@ impl Default for GraspServersSettings {
#[serde(default)] #[serde(default)]
pub struct LocalReposSettings { pub struct LocalReposSettings {
/// The directories scanned for local git repositories. /// The directories scanned for local git repositories.
///
/// Defaults to the user's Desktop and Documents folders. /// Defaults to the user's Desktop and Documents folders.
pub scan_paths: Vec<PathBuf>, pub scan_paths: Vec<PathBuf>,
} }
@@ -107,16 +103,15 @@ impl Default for LocalReposSettings {
} }
} }
/// A remembered association between a local checkout folder and an /// A remembered association between a local checkout folder and an announced repository.
/// announced repository. Recorded when the user clones a repository from /// Recorded when the user clones a repository or picks a folder in the New PR panel.
/// the app or picks a folder in the New PR panel, so the panel can prefill /// The panel can then prefill the folder later without asking again.
/// the folder later without asking again.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct CheckoutRecord { pub struct CheckoutRecord {
/// Local folder of the checkout. /// Local folder of the checkout.
pub path: PathBuf, pub path: PathBuf,
/// Repository address (`30617:<pubkey>:<id>`) as a string. /// Repository address as a string, `30617:<pubkey>:<id>`.
pub addr: String, pub addr: String,
/// Unix seconds of the last use, for freshest-first ordering. /// Unix seconds of the last use, for freshest-first ordering.
pub last_used: u64, pub last_used: u64,
@@ -132,12 +127,12 @@ impl Default for CheckoutRecord {
} }
} }
/// Remembered local checkouts (see [`CheckoutRecord`]). /// Remembered local checkouts, see [`CheckoutRecord`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct CheckoutsSettings { pub struct CheckoutsSettings {
/// The remembered records; the latest use of a path+repo pair replaces /// The remembered records.
/// the older record. /// The latest use of a path and repo pair replaces the older record.
pub records: Vec<CheckoutRecord>, pub records: Vec<CheckoutRecord>,
} }
@@ -145,8 +140,7 @@ pub struct CheckoutsSettings {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct CreateRepositorySettings { pub struct CreateRepositorySettings {
/// The folder the create-repository dialog defaults to; the user's /// The folder the create-repository dialog defaults to, the user's Desktop when unset.
/// Desktop when unset.
pub default_folder: Option<PathBuf>, pub default_folder: Option<PathBuf>,
} }
+8 -7
View File
@@ -9,7 +9,7 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
impl Global for GlobalSettingsStore {} impl Global for GlobalSettingsStore {}
/// The application settings, loaded from disk at startup and persisted whenever they change. /// The application settings, loaded from disk at startup and saved whenever they change.
/// Installed as a global by the app so any part of the UI can read and edit them. /// Installed as a global by the app so any part of the UI can read and edit them.
pub struct SettingsStore { pub struct SettingsStore {
path: PathBuf, path: PathBuf,
@@ -17,7 +17,7 @@ pub struct SettingsStore {
} }
impl SettingsStore { impl SettingsStore {
/// Retrieve the global settings store (created at startup by the app). /// Retrieve the global settings store, created at startup by the app.
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalSettingsStore>().0.clone() cx.global::<GlobalSettingsStore>().0.clone()
} }
@@ -27,9 +27,10 @@ impl SettingsStore {
cx.set_global(GlobalSettingsStore(entity)); cx.set_global(GlobalSettingsStore(entity));
} }
/// Load the settings from `path`, falling back to the defaults when the /// Load the settings from `path`.
/// file is missing or unreadable. Missing keys merge with the defaults, /// Falls back to defaults when the file is missing or unreadable.
/// so older settings files keep working as new settings are added. /// Missing keys merge with the defaults.
/// Older settings files keep working as new settings are added.
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self { pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
Self { Self {
path: path.as_ref().to_path_buf(), path: path.as_ref().to_path_buf(),
@@ -78,8 +79,8 @@ impl SettingsStore {
} }
} }
/// Write the settings to disk, replacing the file atomically /// Write the settings to disk, replacing the file atomically.
/// so a crash mid-write cannot corrupt the settings. /// A crash mid-write cannot corrupt the settings.
fn save(&self) -> Result<()> { fn save(&self) -> Result<()> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
+3 -4
View File
@@ -1,9 +1,8 @@
use nostr::prelude::*; use nostr::prelude::*;
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`. /// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
/// /// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this.
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this. /// The alias reuses the SDK type while keeping repository-specific vocabulary.
/// The alias keeps the repository-specific vocabulary while reusing the SDK type.
pub type RepoAddr = Coordinate; pub type RepoAddr = Coordinate;
/// Build the address of a NIP-34 repository announcement. /// Build the address of a NIP-34 repository announcement.
+24 -21
View File
@@ -1,13 +1,13 @@
use nostr::prelude::*; use nostr::prelude::*;
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note /// ngit and GitWorkshop cover-note extension, kind 1624.
/// attached to an issue, patch or PR by its author or a repository /// A markdown note attached to an issue, patch or PR by its author or a maintainer.
/// maintainer. Not part of the NIP-34 draft; read support for interop. /// Not part of the NIP-34 draft, read support for interop.
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624); pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
/// Whether a kind-1985 label event is a valid annotation of `root`: it /// Whether a kind-1985 label event is a valid annotation of `root`.
/// references the root via a lowercase `e` tag and was authored by the root /// The event references the root with a lowercase `e` tag.
/// author or a maintainer. /// Its author must be the root author or a maintainer.
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool { fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
if event.kind != Kind::Label { if event.kind != Kind::Label {
return false; return false;
@@ -22,8 +22,8 @@ fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) ->
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)) .any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
} }
/// Whether a kind-1985 label event declares the `#t` namespace and carries at /// Whether a kind-1985 label event declares the `#t` namespace.
/// least one `["l", "<value>", "#t"]` label. /// It must also carry at least one `["l", "<value>", "#t"]` label.
fn has_hashtag_labels(event: &Event) -> bool { fn has_hashtag_labels(event: &Event) -> bool {
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"]) event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
&& event.tags.iter().any(|tag| { && event.tags.iter().any(|tag| {
@@ -32,10 +32,11 @@ fn has_hashtag_labels(event: &Event) -> bool {
}) })
} }
/// The effective hashtag labels of `root`: the `t` tags on the event itself /// Effective hashtag labels of `root`.
/// (self-reported by its author) plus all labels attached via authorized /// The `t` tags on the event itself, self-reported by its author.
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all /// Authorized NIP-32 kind-1985 events in the `#t` namespace add more.
/// valid label events contribute (no latest-wins semantics). /// Labels are additive, so all valid label events contribute.
/// There is no latest-wins semantics.
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> { pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
let mut labels: Vec<String> = root let mut labels: Vec<String> = root
.tags .tags
@@ -61,10 +62,11 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -
labels labels
} }
/// The effective subject/title override of `root`, from authorized kind-1985 /// Subject or title override of `root` from authorized kind-1985 label events.
/// label events in the `#subject` namespace. Only the latest event wins /// Only label events in the `#subject` namespace count.
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable /// The latest event wins, per NIP-01 replaceable semantics.
/// semantics). Returns `None` when no valid override exists. /// The tiebreak is the lexicographically larger event id.
/// Returns `None` when no valid override exists.
pub fn subject_override( pub fn subject_override(
root: &Event, root: &Event,
label_events: &[Event], label_events: &[Event],
@@ -103,8 +105,8 @@ pub fn subject_override(
}) })
} }
/// The effective hashtag labels and subject override of `root` in one pass /// Effective hashtag labels and subject override of `root` in one pass.
/// (mirrors ngit's `get_labels_and_subject`). /// Mirrors ngit's `get_labels_and_subject`.
pub fn labels_and_subject( pub fn labels_and_subject(
root: &Event, root: &Event,
label_events: &[Event], label_events: &[Event],
@@ -116,9 +118,10 @@ pub fn labels_and_subject(
) )
} }
/// The effective cover note of `root`: the latest authorized kind-1624 event /// Effective cover note of `root`.
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable /// The latest authorized kind-1624 event wins.
/// semantics). Returns `None` when no valid cover note exists. /// The tiebreak is the lexicographically larger event id, per NIP-01 replaceable semantics.
/// Returns `None` when no valid cover note exists.
pub fn cover_note<'a>( pub fn cover_note<'a>(
root: &Event, root: &Event,
cover_notes: &'a [Event], cover_notes: &'a [Event],
+2 -2
View File
@@ -2,10 +2,10 @@ use nostr::prelude::*;
use crate::RepoAddr; use crate::RepoAddr;
/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format"). /// Target of a `nostr://` clone URL, as defined by NIP-34.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloneTarget { pub enum CloneTarget {
/// `nostr://<naddr1...>` direct repository address. /// `nostr://<naddr1...>` encodes a direct repository address.
Addr(RepoAddr), Addr(RepoAddr),
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>` /// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
UserRepo { UserRepo {
+15 -14
View File
@@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet};
use nostr::prelude::*; use nostr::prelude::*;
/// A NIP-22 comment thread: a top-level comment on the root event and its /// A NIP-22 comment thread, a top-level comment on the root event.
/// nested replies (oldest first at every level). /// Nested replies are ordered oldest first at every level.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentThread { pub struct CommentThread {
/// The thread's top-level comment. /// The thread's top-level comment.
@@ -12,8 +12,8 @@ pub struct CommentThread {
pub replies: Vec<CommentThread>, pub replies: Vec<CommentThread>,
} }
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for /// The direct parent id of a comment, from its NIP-22 lowercase `e` tag.
/// comments without one. /// `None` when no `e` tag is present.
fn comment_parent(event: &Event) -> Option<EventId> { fn comment_parent(event: &Event) -> Option<EventId> {
event event
.tags .tags
@@ -23,14 +23,14 @@ fn comment_parent(event: &Event) -> Option<EventId> {
.and_then(|id| EventId::parse(id).ok()) .and_then(|id| EventId::parse(id).ok())
} }
/// Group the comments on a root event (issue / patch / PR) into NIP-22 /// Group the comments on a root issue, patch or PR into NIP-22 threads.
/// threads. A comment whose parent is the root itself starts a thread; other /// A comment whose parent is the root starts a thread.
/// comments nest under their parent comment. Threads and replies are ordered /// Other comments nest under their parent comment.
/// oldest-first. Replies whose parent comment is missing (e.g. not fetched) /// Threads and replies are ordered oldest first.
/// are placed as top-level threads so they are not dropped. /// Replies with a missing parent are made top-level threads, so none are dropped.
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> { pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
// Index comments by their parent id. Comments without a parent tag are // Index comments by their parent id.
// treated as replying to the root event itself. // Comments without a parent tag reply to the root event itself.
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new(); let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
for comment in comments { for comment in comments {
let parent = comment_parent(comment).unwrap_or(root.id); let parent = comment_parent(comment).unwrap_or(root.id);
@@ -65,8 +65,8 @@ pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
let mut threads = build(root.id, &children, &mut visited); let mut threads = build(root.id, &children, &mut visited);
// Orphan replies: their parent comment is unknown, so they never appear // Orphan replies have an unknown parent comment, so they never reach the root tree.
// in the tree rooted at the root event; surface them as top-level threads. // Surface them as top-level threads so they are not dropped.
let mut orphans: Vec<&Event> = comments let mut orphans: Vec<&Event> = comments
.iter() .iter()
.filter(|event| !visited.contains(&event.id)) .filter(|event| !visited.contains(&event.id))
@@ -148,7 +148,8 @@ mod tests {
.expect("signed event"); .expect("signed event");
let a = comment(&keys, Some(&root), "a", 100); let a = comment(&keys, Some(&root), "a", 100);
// `missing` is not in the comment set; its reply should still show up. // `missing` is not in the comment set.
// Its reply should still show up.
let missing = EventBuilder::new(Kind::Comment, "missing") let missing = EventBuilder::new(Kind::Comment, "missing")
.finalize(&keys) .finalize(&keys)
.expect("signed event"); .expect("signed event");
+8 -11
View File
@@ -2,11 +2,10 @@ use std::collections::HashSet;
use nostr::prelude::*; use nostr::prelude::*;
/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide /// NIP-09 deletion requests and NIP-62 vanish requests.
/// deleted events before they reach the UI. /// Built from the kind-5 and kind-62 events in the local database.
/// /// Deleted events are hidden before they reach the UI.
/// Built from the kind-5 / kind-62 events stored in the local database; /// Pass any event through [`Deletions::is_deleted`] before showing it.
/// pass any event through [`Deletions::is_deleted`] before displaying it.
pub struct Deletions { pub struct Deletions {
/// `(deleted event id, expected author)` from `e` tags of kind-5 events. /// `(deleted event id, expected author)` from `e` tags of kind-5 events.
ids: HashSet<(EventId, PublicKey)>, ids: HashSet<(EventId, PublicKey)>,
@@ -34,8 +33,8 @@ impl Deletions {
.map(|c| (c, event.pubkey, event.created_at)), .map(|c| (c, event.pubkey, event.created_at)),
); );
} else if event.kind == Kind::RequestToVanish { } else if event.kind == Kind::RequestToVanish {
// Client-side we can't verify which relay the request targeted, // Client-side we can't verify which relay the request targeted.
// so any vanish request is honored for the author's events. // Any vanish request is then honored for the author's events.
vanished.push((event.pubkey, event.created_at)); vanished.push((event.pubkey, event.created_at));
} }
} }
@@ -48,10 +47,8 @@ impl Deletions {
} }
/// Whether the event is covered by a valid deletion or vanish request. /// Whether the event is covered by a valid deletion or vanish request.
/// /// A request is valid when its author matches the deleted event's author, per NIP-09.
/// A request is only valid when its author matches the deleted event's /// Addressable events are deleted up to the request's `created_at`.
/// author (NIP-09); addressable events are deleted up to the request's
/// `created_at`.
pub fn is_deleted(&self, event: &Event) -> bool { pub fn is_deleted(&self, event: &Event) -> bool {
if self if self
.vanished .vanished
+37 -42
View File
@@ -25,7 +25,7 @@ pub fn announcement(addr: &RepoAddr) -> Filter {
.identifier(addr.identifier.clone()) .identifier(addr.identifier.clone())
} }
/// Latest state event (refs / HEAD) for a repository. /// Latest state event for a repository, carrying refs and HEAD.
pub fn state(addr: &RepoAddr) -> Filter { pub fn state(addr: &RepoAddr) -> Filter {
Filter::new() Filter::new()
.kind(Kind::RepoState) .kind(Kind::RepoState)
@@ -33,18 +33,18 @@ pub fn state(addr: &RepoAddr) -> Filter {
.identifier(addr.identifier.clone()) .identifier(addr.identifier.clone())
} }
/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs, /// All NIP-34 activity addressed to a repository via its `#a` tag.
/// patches, statuses and comments (kind 1111). /// Covers issues, PRs, patches, statuses and kind-1111 comments.
/// /// The `a` tag is optional on status events per NIP-34.
/// Note: the `a` tag on status events is optional per NIP-34, so statuses /// Statuses published without it are not matched here.
/// published without it won't be matched here.
pub fn activity(addr: &RepoAddr) -> Filter { pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
} }
/// Status events (`1630..=1633`) referencing any of the given root events /// Status events, kinds `1630..=1633`, referencing any of the given root events.
/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync /// They are matched via the `#e` tag.
/// reconciles them in a single session instead of one per root. /// One filter covers all roots.
/// A negentropy sync reconciles them in a single session, not one per root.
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter { pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new() Filter::new()
.kinds([ .kinds([
@@ -56,33 +56,29 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
.events(roots) .events(roots)
} }
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing /// Cover notes and NIP-32 label events referencing any of the given root events.
/// any of the given root events (`#e` tag), fetched per root like comments /// These are kinds 1624 and 1985, matched via the `#e` tag.
/// and statuses because they carry no repository `a` tag. Batched, like /// Because they carry no repository `a` tag, they are fetched by root like comments.
/// [`statuses_for`]. /// Batched, like [`statuses_for`].
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter { pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new() Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label]) .kinds([crate::COVER_NOTE_KIND, Kind::Label])
.events(roots) .events(roots)
} }
/// A user's grasp list (kind `10317`). /// A user's grasp list, kind `10317`.
pub fn grasp_list(public_key: PublicKey) -> Filter { pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new() Filter::new()
.kind(Kind::GitUserGraspList) .kind(Kind::GitUserGraspList)
.author(public_key) .author(public_key)
} }
/// NIP-22 comments (kind `1111`) referencing any of the given root events /// NIP-22 comments, kind `1111`, referencing any of the given root events.
/// (issues, patches, PRs). /// The roots are issues, patches and PRs.
/// /// Comments carry no repository `a` tag, so they are fetched by root reference.
/// Comments carry no repository `a` tag, so they must be fetched by their /// NIP-22 names the uppercase `E` tag as the thread root, used by ngit.
/// root reference. NIP-22 defines the uppercase `E` tag as the thread root /// Some clients, including Signed, use a lowercase `e` tag, so both are matched.
/// (used by ngit), but some clients (including Signed) use a lowercase `e` /// Returns two filters, since combining `#E` and `#e` would AND the conditions.
/// tag, so both are matched.
///
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
/// combined into one.
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> { pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect(); let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
if roots.is_empty() { if roots.is_empty() {
@@ -105,42 +101,41 @@ pub fn announcements_by(public_key: PublicKey) -> Filter {
.author(public_key) .author(public_key)
} }
/// All repository announcements (for global discovery). /// All repository announcements, for global discovery.
/// /// Unbounded, intended for negentropy sync, which reconciles sets regardless of size.
/// Unbounded: intended for negentropy sync, which reconciles sets /// Local queries with this filter are served by LMDB, staying fast as the database grows.
/// efficiently regardless of size. Local database queries with this
/// filter are served by LMDB, so they stay fast as the database grows.
pub fn all_announcements() -> Filter { pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement) Filter::new().kind(Kind::GitRepoAnnouncement)
} }
/// How far back deletion requests are fetched and stored. /// How far back deletion requests are fetched and stored.
/// /// A deletion request can only target events created before it.
/// A deletion request can only target events created before it, and NIP-34 /// NIP-34 events are far younger than this window.
/// events are all far younger than this window, so older requests can never /// Older requests can never match anything shown.
/// match anything shown. Bounding the window keeps the kind-5/62 set (one of /// Bounding the window keeps the kind-5 and kind-62 set from a full sync reconciliation.
/// the largest on public relays) from being fully reconciled on every sync. /// That set is one of the largest on public relays.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical /// `now` minus [`DELETIONS_LOOKBACK`].
/// filters hash the same and the backend's sync dedup can match them. /// Quantized to whole days so identical filters hash the same.
/// This lets the backend's sync dedup match identical filters.
fn deletions_since() -> Timestamp { fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs(); let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
} }
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within /// All deletion-related events within [`DELETIONS_LOOKBACK`].
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other /// These are NIP-09 kind `5` and NIP-62 kind `62`.
/// event can be shown. /// Deletion requests must be known before any other event is shown.
pub fn deletions() -> Filter { pub fn deletions() -> Filter {
Filter::new() Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish]) .kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since()) .since(deletions_since())
} }
/// Deletion events relevant to a single repository: requests authored by /// Deletion events relevant to a single repository.
/// the repository owner and requests addressed to the repository /// Requests authored by the repository owner.
/// coordinate (`#a` tag). /// Requests addressed to the repository coordinate via its `#a` tag.
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> { pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
vec![ vec![
Filter::new() Filter::new()
+79 -78
View File
@@ -5,16 +5,16 @@ use nostr::prelude::*;
use crate::RepoAddr; use crate::RepoAddr;
/// Parsed NIP-34 repository announcement (plain data, ready for the UI). /// Parsed NIP-34 repository announcement, plain data ready for the UI.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement { pub struct Announcement {
/// ID of the announcement event itself. /// ID of the announcement event itself.
pub event_id: EventId, pub event_id: EventId,
/// Repository ID (`d` tag). /// Repository ID, the `d` tag.
pub id: String, pub id: String,
/// Author of the announcement event. /// Author of the announcement event.
pub owner: PublicKey, pub owner: PublicKey,
/// When the announcement was published (for latest-wins resolution). /// When the announcement was published, used for latest-wins resolution.
pub created_at: Timestamp, pub created_at: Timestamp,
pub name: Option<SharedString>, pub name: Option<SharedString>,
pub description: Option<SharedString>, pub description: Option<SharedString>,
@@ -24,36 +24,38 @@ pub struct Announcement {
pub clone: Vec<Url>, pub clone: Vec<Url>,
/// Relays the repository monitors for patches and issues. /// Relays the repository monitors for patches and issues.
pub relays: Vec<RelayUrl>, pub relays: Vec<RelayUrl>,
/// Earliest unique commit ID (`r` tag with `euc` marker). /// Earliest unique commit ID, the `r` tag with `euc` marker.
pub euc: Option<String>, pub euc: Option<String>,
/// Other recognized maintainers. /// Other recognized maintainers.
pub maintainers: Vec<PublicKey>, pub maintainers: Vec<PublicKey>,
/// Value of a `u` tag, if any: this repository is a subordinate fork of /// Value of a `u` tag, if any.
/// the referenced upstream (NIP-34). /// Marks the repository as a subordinate fork of the upstream, per NIP-34.
pub upstream: Option<Upstream>, pub upstream: Option<Upstream>,
/// Hashtags labelling the repository (`t` tags). /// Hashtags labelling the repository, the `t` tags.
pub hashtags: Vec<String>, pub hashtags: Vec<String>,
} }
/// The `u` tag of a fork announcement (NIP-34) /// The `u` tag of a fork announcement, per NIP-34.
/// the repository this one is a subordinate fork of. The first value is /// The first value is the upstream coordinate or a git URL.
/// the upstream coordinate (`30617:<pubkey>:<id>`) or a git URL. /// The coordinate form is `30617:<pubkey>:<id>`.
/// The second is an optional relay hint for the upstream. /// The second value is an optional relay hint for the upstream.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream { pub struct Upstream {
/// Raw first value of the `u` tag (coordinate or git URL). /// Raw first value of the `u` tag, a coordinate or git URL.
pub raw: String, pub raw: String,
/// The upstream `30617:<pubkey>:<id>` coordinate, when the `u` tag /// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
/// references a NIP-34 repository; `None` for the git-URL form. /// `None` for the git-URL form.
pub addr: Option<RepoAddr>, pub addr: Option<RepoAddr>,
/// Relay hint for the upstream, if the `u` tag carries one. /// Relay hint for the upstream, if the `u` tag carries one.
pub relay_hint: Option<RelayUrl>, pub relay_hint: Option<RelayUrl>,
} }
impl Upstream { impl Upstream {
/// Parse the `u` tag values. The first is the upstream coordinate or a /// Parse the `u` tag values.
/// git URL (the coordinate form may append `|git-url`; the coordinate is /// The first value is the upstream coordinate or a git URL.
/// the part before the first `|`), the second an optional relay hint. /// The coordinate form may append `|git-url`.
/// The coordinate is the part before the first `|`.
/// The second value is an optional relay hint.
fn parse(raw: &str, relay_hint: Option<&str>) -> Self { fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw); let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate let addr = coordinate
@@ -67,8 +69,8 @@ impl Upstream {
} }
} }
/// Text for display: the upstream coordinate when it is a NIP-34 /// Text for display.
/// repository, otherwise the raw `u` value (git-URL form). /// The upstream coordinate for a NIP-34 repository, else the raw `u` value.
pub fn display(&self) -> SharedString { pub fn display(&self) -> SharedString {
match &self.addr { match &self.addr {
Some(addr) => SharedString::from(addr.to_string()), Some(addr) => SharedString::from(addr.to_string()),
@@ -77,8 +79,8 @@ impl Upstream {
} }
} }
/// Subject of a NIP-34 issue or pull request event: the `subject` tag, /// Subject of a NIP-34 issue or pull request event.
/// falling back to the first non-empty line of the content. /// Taken from the `subject` tag, else the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> SharedString { pub fn activity_subject(event: &Event) -> SharedString {
let subject = event let subject = event
.tags .tags
@@ -101,13 +103,13 @@ pub fn activity_subject(event: &Event) -> SharedString {
.unwrap_or(SharedString::from("Untitled")) .unwrap_or(SharedString::from("Untitled"))
} }
/// The patch set of a pull request: the root patch event (kind `1617`) the /// The patch set of a pull request.
/// PR references via its `e` tag, plus every patch of the set chained to it /// The PR references the root patch event, kind `1617`, via its `e` tag.
/// with NIP-10 `e` reply tags, in series order (oldest first). When the PR /// Every patch of the set is chained to the previous one with NIP-10 `e` reply tags.
/// has no `e` tag, falls back to the patch producing the PR's tip commit /// They are returned in series order, oldest first.
/// (its `commit`/`r` tag, per NIP-34) and walks the reply chain backward to /// A PR without an `e` tag falls back to the patch producing its tip commit.
/// the root. /// The tip commit is the PR's `commit` or `r` tag, per NIP-34.
/// /// The reply chain is then walked backward to the root.
/// Returns an empty list when no patch event can be linked to the PR. /// Returns an empty list when no patch event can be linked to the PR.
pub fn pull_request_patches<'a>( pub fn pull_request_patches<'a>(
pr: &Event, pr: &Event,
@@ -115,17 +117,18 @@ pub fn pull_request_patches<'a>(
) -> Vec<&'a Event> { ) -> Vec<&'a Event> {
let patches: Vec<&'a Event> = patches.into_iter().collect(); let patches: Vec<&'a Event> = patches.into_iter().collect();
// The PR references its root patch via an `e` tag; follow the NIP-10 // The PR references its root patch via an `e` tag.
// reply chain forward from there (each patch of the set replies to the // Follow the NIP-10 reply chain forward from there.
// previous one). Among several replies (a revision), the newest wins. // Each patch replies to the previous one, and among several replies the newest wins.
if let Some(root_id) = pr.tags.event_ids().next() if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id) && let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{ {
return forward_series(root, &patches); return forward_series(root, &patches);
} }
// No `e` tag: the last patch of the set carries the PR's tip commit in // The PR has no `e` tag.
// its `commit`/`r` tag; walk the reply chain backward to the root. // The last patch of the set carries the tip commit in its `commit` or `r` tag.
// Walk the reply chain backward to the root.
let Some(tip) = current_commit_of(pr) else { let Some(tip) = current_commit_of(pr) else {
return Vec::new(); return Vec::new();
}; };
@@ -156,10 +159,9 @@ pub fn pull_request_patches<'a>(
series series
} }
/// The patch content of a pull request: the contents of every patch event of /// The patch content of a pull request.
/// its patch set (see [`pull_request_patches`]) joined in series order, /// The contents of its patch set, see [`pull_request_patches`], joined in series order.
/// falling back to the PR's own content for older PRs that carried the /// Older PRs that carried the patch inline fall back to their own content.
/// patch inline.
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String { pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
let patches: Vec<&'a Event> = patches.into_iter().collect(); let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied()); let series = pull_request_patches(pr, patches.iter().copied());
@@ -173,7 +175,7 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
.join("\n") .join("\n")
} }
/// The chain of patches replying to `root` (NIP-10 `e` tags), oldest first. /// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root]; let mut series = vec![root];
loop { loop {
@@ -195,7 +197,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
series series
} }
/// The `c` tag of an event (tip of the proposed branch), as hex. /// The `c` tag of an event, the tip of the proposed branch, as hex.
fn current_commit_of(event: &Event) -> Option<String> { fn current_commit_of(event: &Event) -> Option<String> {
event event
.tags .tags
@@ -206,8 +208,8 @@ fn current_commit_of(event: &Event) -> Option<String> {
}) })
} }
/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients /// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
/// can find existing patches for a specific commit. /// It lets clients find existing patches for a specific commit.
fn patch_produces_commit(patch: &Event, commit: &str) -> bool { fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
patch patch
.tags .tags
@@ -219,7 +221,8 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
} }
impl Announcement { impl Announcement {
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing. /// Parse a kind `30617` event.
/// Returns `None` when the kind is wrong or the `d` tag is missing.
pub fn from_event(event: &Event) -> Option<Self> { pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement { if event.kind != Kind::GitRepoAnnouncement {
return None; return None;
@@ -251,8 +254,8 @@ impl Announcement {
_ => {} _ => {}
} }
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it // The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
// manually (first wins). // Only the first `u` tag is used.
if upstream.is_none() && tag.kind() == "u" { if upstream.is_none() && tag.kind() == "u" {
let values = tag.as_slice(); let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default(); let raw = values.get(1).map(String::as_str).unwrap_or_default();
@@ -284,11 +287,10 @@ impl Announcement {
crate::repo_addr(self.owner, self.id.clone()) crate::repo_addr(self.owner, self.id.clone())
} }
/// Whether this announcement is a fork of the repository at `base`: /// Whether this announcement is a fork of the repository at `base`.
/// its `u` tag points at `base` (also covers permanent forks whose EUC /// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
/// diverged), or it shares `base`'s earliest unique commit (EUC) and is /// Or it shares `base`'s earliest unique commit and is not the base itself.
/// not the base repository itself. Read-only discovery input: nothing /// Read-only discovery input, nothing here is published back to nostr.
/// here is published back to nostr.
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool { pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
if self.addr() == *base { if self.addr() == *base {
return false; return false;
@@ -306,10 +308,10 @@ impl Announcement {
.unwrap_or(SharedString::from("No description")) .unwrap_or(SharedString::from("No description"))
} }
/// The effective maintainers of this repository: the announced /// The effective maintainers of this repository.
/// `maintainers` plus the announcement author, who asserts themselves as /// The announced `maintainers` plus the announcement author.
/// a maintainer of the primary project unless a `u` tag marks this /// The author asserts themselves as a maintainer of the primary project.
/// repository as a subordinate fork (NIP-34). /// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
pub fn effective_maintainers(&self) -> Vec<PublicKey> { pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone(); let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) { if self.upstream.is_none() && !maintainers.contains(&self.owner) {
@@ -318,8 +320,8 @@ impl Announcement {
maintainers maintainers
} }
/// The `git clone` URLs for this repository, deduplicated while /// The `git clone` URLs for this repository, deduplicated.
/// preserving the announced order (deterministic across calls). /// The announced order is preserved, making output deterministic across calls.
pub fn clone_urls(&self) -> Vec<SharedString> { pub fn clone_urls(&self) -> Vec<SharedString> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
self.clone self.clone
@@ -464,8 +466,8 @@ mod tests {
let announcement = Announcement::from_event(&event).expect("parses"); let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag"); let upstream = announcement.upstream.expect("parses the u tag");
// The coordinate part resolves to a repository address; the raw // The coordinate part resolves to a repository address.
// value keeps the `|git-url` suffix. // The raw value keeps the `|git-url` suffix.
assert_eq!( assert_eq!(
upstream.addr, upstream.addr,
Some(crate::repo_addr( Some(crate::repo_addr(
@@ -489,8 +491,8 @@ mod tests {
#[test] #[test]
fn parses_git_url_upstream() { fn parses_git_url_upstream() {
// The `u` tag may reference a non-nostr upstream by git URL only; // The `u` tag may reference a non-nostr upstream by git URL only.
// there is no repository address to navigate to. // There is no repository address to navigate to.
let event = announcement_event(&[ let event = announcement_event(&[
&["d", "my-fork"], &["d", "my-fork"],
&["u", "https://example.com/upstream.git"], &["u", "https://example.com/upstream.git"],
@@ -508,7 +510,7 @@ mod tests {
#[test] #[test]
fn is_fork_of_matches_the_u_tag_coordinate() { fn is_fork_of_matches_the_u_tag_coordinate() {
// The base repository (announced by the `u`-tag's owner). // The base repository, announced by the `u` tag's owner.
let base = crate::repo_addr( let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream", "upstream",
@@ -516,21 +518,21 @@ mod tests {
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]); let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
let fork = Announcement::from_event(&event).expect("parses"); let fork = Announcement::from_event(&event).expect("parses");
// A `u` tag pointing at the base address marks a fork even when // A `u` tag pointing at the base address marks a fork.
// neither side announces an EUC. // This holds even when neither side announces an EUC.
assert!(fork.is_fork_of(&base, None)); assert!(fork.is_fork_of(&base, None));
} }
#[test] #[test]
fn is_fork_of_matches_a_shared_euc() { fn is_fork_of_matches_a_shared_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
// The base repo has no `u` tag; it announces the family EUC. // The base repo has no `u` tag. It announces the family EUC.
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]); let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&base_event).expect("parses"); let base = Announcement::from_event(&base_event).expect("parses");
let base_addr = base.addr(); let base_addr = base.addr();
// A fork (no `u` tag; a pure mirror or cross-hosted clone) shares // A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC.
// the EUC, so clients of the family can find it. // Clients of the family can then find it.
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]); let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
let fork = Announcement::from_event(&fork_event).expect("parses"); let fork = Announcement::from_event(&fork_event).expect("parses");
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref())); assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
@@ -549,8 +551,8 @@ mod tests {
#[test] #[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() { fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
// A permanent fork re-announces its EUC (first commit after the // A permanent fork re-announces its EUC, the first commit after the fork.
// fork); only the `u` tag still relates it to the base. // Only the `u` tag still relates it to the base.
let base = crate::repo_addr( let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream", "upstream",
@@ -573,8 +575,7 @@ mod tests {
let base = Announcement::from_event(&event).expect("parses"); let base = Announcement::from_event(&event).expect("parses");
let base_addr = base.addr(); let base_addr = base.addr();
// The base announcement matches its own EUC, but is not a fork of // The base announcement matches its own EUC but is not a fork of itself.
// itself.
assert!(!base.is_fork_of(&base_addr, base.euc.as_deref())); assert!(!base.is_fork_of(&base_addr, base.euc.as_deref()));
} }
@@ -585,8 +586,8 @@ mod tests {
let announcement = Announcement::from_event(&event).expect("parses"); let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers(); let maintainers = announcement.effective_maintainers();
// The owner asserts themselves as a maintainer of the primary // The owner asserts themselves as a maintainer of the primary project, per NIP-34.
// project (NIP-34), alongside the announced co-maintainers. // Announced co-maintainers are included too.
assert_eq!(maintainers.len(), 2); assert_eq!(maintainers.len(), 2);
assert!(maintainers.contains(&announcement.owner)); assert!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"))); assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
@@ -603,8 +604,8 @@ mod tests {
let announcement = Announcement::from_event(&event).expect("parses"); let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers(); let maintainers = announcement.effective_maintainers();
// A `u` tag marks the repository as a subordinate fork: the author // A `u` tag marks the repository as a subordinate fork.
// is not a maintainer of the primary project (NIP-34). // The author is then not a maintainer of the primary project, per NIP-34.
assert!(!maintainers.contains(&announcement.owner)); assert!(!maintainers.contains(&announcement.owner));
assert_eq!( assert_eq!(
maintainers, maintainers,
@@ -632,7 +633,7 @@ mod tests {
#[test] #[test]
fn pull_request_patch_falls_back_to_inline_content() { fn pull_request_patch_falls_back_to_inline_content() {
// Older PRs carried the patch in the content; no linked patch event. // Older PRs carried the patch in the content and link no patch event.
let pr = pr_event("patch-inline", vec![]); let pr = pr_event("patch-inline", vec![]);
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline"); assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
@@ -659,8 +660,8 @@ mod tests {
#[test] #[test]
fn pull_request_patch_joins_the_whole_patch_set() { fn pull_request_patch_joins_the_whole_patch_set() {
// NIP-34: a PR references the root patch; later patches of the set // A PR references the root patch, per NIP-34.
// reply to the previous one (NIP-10 `e` tags). // Later patches of the set reply to the previous one via NIP-10 `e` tags.
let root = patch_event("patch-one", vec![], 100); let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]); let pr = pr_event("description", vec![Tag::event(root.id)]);
@@ -712,8 +713,8 @@ mod tests {
#[test] #[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() { fn pull_request_patches_finds_the_set_via_the_tip_commit() {
// PRs without an `e` tag: the last patch of the set carries the tip // PRs without an `e` tag fall back to the patch producing the tip commit.
// commit in its `r` tag; walk the reply chain backward to the root. // Walk the reply chain backward to the root.
let root = patch_event("patch-one", vec![], 100); let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111"; let tip = "1111111111111111111111111111111111111111";
let last = patch_event( let last = patch_event(
+6 -7
View File
@@ -1,10 +1,10 @@
use nostr::prelude::*; use nostr::prelude::*;
/// Build a kind `30618` repository state event from refs and HEAD. /// Build a kind `30618` repository state event from refs and HEAD.
/// /// `refs` are `(refname, commit-id)` pairs, e.g. `refs/heads/main`.
/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head` /// `head` is the short branch name HEAD points to.
/// is the short branch name HEAD points to, published as /// It is published as `ref: refs/heads/<branch>`.
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id. /// The `d` tag matches the repository id.
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())]; let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
for (name, commit) in refs { for (name, commit) in refs {
@@ -19,9 +19,8 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E
} }
/// Parse a kind `30618` repository state event into refs and HEAD. /// Parse a kind `30618` repository state event into refs and HEAD.
/// /// `refs` are `(refname, commit-id)` pairs.
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to /// `head` is the branch pointed to by the `HEAD` tag, if any.
/// by the `HEAD` tag, if any.
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) { pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
let mut refs = Vec::new(); let mut refs = Vec::new();
let mut head = None; let mut head = None;
+6 -6
View File
@@ -1,6 +1,6 @@
use nostr::prelude::*; use nostr::prelude::*;
/// Status of a root patch, pull request or issue (kinds `1630..=1633`). /// Status of a root patch, pull request or issue, kinds `1630..=1633`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RepoStatus { pub enum RepoStatus {
Open, Open,
@@ -30,9 +30,9 @@ impl RepoStatus {
} }
} }
/// Check whether an event references the given root event via an `e` or `E` /// Whether an event references the given root event via an `e` or `E` tag.
/// tag. NIP-10 / NIP-34 use the lowercase `e` tag; NIP-22 comments (kind /// NIP-10 and NIP-34 use the lowercase `e` tag.
/// `1111`) use the uppercase `E` tag for the root of the thread. /// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
pub fn references_root(event: &Event, root: &EventId) -> bool { pub fn references_root(event: &Event, root: &EventId) -> bool {
let root = root.to_hex(); let root = root.to_hex();
event event
@@ -41,8 +41,8 @@ pub fn references_root(event: &Event, root: &EventId) -> bool {
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
} }
/// Resolve the status of a root event per NIP-34: /// Resolve the status of a root event per NIP-34.
/// the most recent status event from the root author or a maintainer wins. /// The most recent status event from the root author or a maintainer wins.
/// Defaults to [`RepoStatus::Open`]. /// Defaults to [`RepoStatus::Open`].
pub fn resolve_status<'a, I>( pub fn resolve_status<'a, I>(
status_events: I, status_events: I,
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -12,11 +12,10 @@ use nostr_sdk::prelude::*;
use crate::signer::UniversalSigner; use crate::signer::UniversalSigner;
/// Open (or create) the LMDB database at `db_path` and build a client /// Open or create the LMDB database at `db_path`.
/// configured for Signed, together with a fresh signer. /// Build a Signed client and a fresh signer for it.
///
/// The SDK manages its own internal tokio runtime. /// The SDK manages its own internal tokio runtime.
/// the returned client can be driven by GPUI's executors. /// The returned client can be driven by GPUI's executors.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> { pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
@@ -26,7 +25,7 @@ pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, Universal
Ok(with_database(signer, database)) Ok(with_database(signer, database))
} }
/// In-memory database on wasm (no LMDB available). /// In-memory database on wasm, LMDB is unavailable there.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn new_backend() -> Result<(Client, UniversalSigner)> { pub fn new_backend() -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
+3 -2
View File
@@ -31,8 +31,9 @@ impl UniversalSignerError {
} }
} }
/// A type-erased signer whose inner signer can be swapped in-place /// A type-erased signer whose inner signer can be swapped in-place.
/// (e.g. after login/logout). All clones see the swap. /// Swaps happen after login or logout.
/// All clones see the swap.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct UniversalSigner { pub struct UniversalSigner {
inner: Arc<RwLock<Arc<dyn InnerSigner>>>, inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
+3 -4
View File
@@ -1,12 +1,11 @@
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
/// A lightweight "something changed" signal for the UI. /// A lightweight change notification for the UI.
/// /// Heavy data stays in the database, consumers re-query on receipt.
/// Heavy data stays in the database; consumers re-query on receipt.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Update { pub struct Update {
pub kind: Kind, pub kind: Kind,
/// First `a` tag value of the event, if any (e.g. the repository coordinate). /// First `a` tag value of the event, if any, for example the repository coordinate.
pub coordinate: Option<Coordinate>, pub coordinate: Option<Coordinate>,
pub author: PublicKey, pub author: PublicKey,
pub event_id: EventId, pub event_id: EventId,
+233 -245
View File
@@ -18,8 +18,9 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore; use crate::git_store::GitStore;
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...` /// Keyring entry for the user credential.
/// with an embedded `?master=<nsec>` NIP-46 session key). /// It is an `nsec1...` key or a `bunker://...` URI.
/// The URI embeds a `?master=<nsec>` NIP-46 session key.
pub const USER_KEYRING: &str = "Signed Safe Storage"; pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses. /// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -32,34 +33,35 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://profiles.nostr1.com", "wss://profiles.nostr1.com",
]; ];
/// Relays used for indexing user's relay list (NIP-65). /// 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"]; pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// How long an identical fetch/sync request is suppressed after it started. /// How long an identical fetch or sync request is suppressed after it started.
/// A second panel for the same repository (or the global and per-author /// A second panel for the same repository does not duplicate a live sync.
/// list stores at login) doesn't duplicate a sync that just ran; after the /// The global and per-author list stores at login share this dedup.
/// window, re-fetching is allowed again so data stays fresh. /// After the window, re-fetching is allowed again so data stays fresh.
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60); const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BackendEvent { pub enum BackendEvent {
/// User has no signer configured. /// User has no signer configured.
SignerRequired, SignerRequired,
/// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a /// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key.
/// passphrase is required to decrypt it before the session can resume. /// A passphrase is required to decrypt it before the session can resume.
PassphraseRequired, PassphraseRequired,
/// The signer has changed (login/logout/account switch). /// The signer changed on login, logout or account switch.
SignerChanged, SignerChanged,
/// Relay bootstrap finished. /// Relay bootstrap finished.
Connected, Connected,
/// A new event was received from a relay and stored in the database. /// A new event was received from a relay and stored in the database.
NostrUpdate(Update), NostrUpdate(Update),
/// A negentropy sync completed; the database was updated directly, /// A negentropy sync completed.
/// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired /// The database was updated directly, so stores should re-query.
/// for synced events). /// No [`BackendEvent::NostrUpdate`] is fired for synced events.
Synced, Synced,
/// A negentropy sync is in flight. Stores may re-query to render /// A negentropy sync is in flight.
/// incrementally; UI can show `current`/`total` progress. /// Stores may re-query to render incrementally.
/// UI can show `current` and `total` progress.
SyncProgress { SyncProgress {
/// Total events to process. /// Total events to process.
total: u64, total: u64,
@@ -81,28 +83,29 @@ impl BackendEvent {
} }
} }
/// Global backend entity: owns the nostr client, the signer and the /// The global backend entity.
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the /// Owns the nostr client, the signer and the notification pump.
/// local database when relevant updates arrive. /// Stores subscribe to [`BackendEvent`].
/// They re-query the local database when relevant updates arrive.
pub struct Backend { pub struct Backend {
client: Client, client: Client,
signer: UniversalSigner, signer: UniversalSigner,
current_user: Option<PublicKey>, current_user: Option<PublicKey>,
connected: bool, connected: bool,
sync_progress: Option<(u64, u64)>, sync_progress: Option<(u64, u64)>,
/// Whether the stored credential is NIP-49 encrypted and a passphrase /// True when the stored credential is NIP-49 encrypted.
/// is still needed to resume the session. /// A passphrase is still needed to resume the session.
passphrase_required: bool, passphrase_required: bool,
/// Fingerprints of recently started fetches/syncs (relay + filter set), /// Fingerprints of recently started fetches and syncs, a relay plus filter set.
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into /// Duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into one.
/// one. Entries are pruned lazily on the next request. /// Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>, recent_fetches: HashMap<u64, Instant>,
/// Repositories a push (mirror or checkout) is currently in flight /// Repositories with a push in flight, mirror or checkout based.
/// for. Concurrent pushes of the same refs — two panels of the same /// Concurrent pushes of the same refs make the losing push fail server-side.
/// repository, or the banner push racing the header's Republish — make /// The rejection is a compare-and-swap error from the server.
/// the losing push fail server-side with a compare-and-swap rejection /// Two panels of the same repository can race.
/// ("cannot lock ref … is at … but expected …"), so pushes are /// The banner push can also race the header's Republish.
/// single-flight per repository. /// Pushes are single-flight per repository.
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>, pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -111,9 +114,8 @@ struct GlobalBackend(Entity<Backend>);
impl Global for GlobalBackend {} impl Global for GlobalBackend {}
/// Removes its repository from the in-flight push set when dropped, so a /// Removes its repository from the in-flight push set when dropped.
/// push task that is cancelled (e.g. its panel closed mid-push) can never /// A push task cancelled by its panel closing cannot leave the repository locked.
/// leave the repository locked for the rest of the session.
struct PushGuard { struct PushGuard {
repos: Arc<Mutex<HashSet<RepoAddr>>>, repos: Arc<Mutex<HashSet<RepoAddr>>>,
addr: RepoAddr, addr: RepoAddr,
@@ -179,8 +181,9 @@ impl Backend {
this this
} }
/// Bootstrap the client: connect to the default relays (indexers as /// Bootstrap the client.
/// discovery-only) and restore the saved session, if any. /// Connect to the default relays, with the indexers as discovery-only.
/// Restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) { fn bootstrap(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -217,10 +220,9 @@ impl Backend {
self.restore_session(cx); self.restore_session(cx);
} }
/// Restore the saved session from the keyring. Emits /// Restore the saved session from the keyring.
/// [`BackendEvent::SignerRequired`] if no credential is stored, or /// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
/// [`BackendEvent::PassphraseRequired`] if the stored identity is /// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
/// NIP-49 encrypted.
pub fn restore_session(&mut self, cx: &mut Context<Self>) { pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
@@ -254,8 +256,8 @@ impl Backend {
signer.auth_url_handler(SignedAuthUrlHandler); signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?; this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") { } else if content.starts_with("ncryptsec1") {
// Encrypted identity: a passphrase is required to // Encrypted identity.
// decrypt it before the session can resume. // A passphrase is required to decrypt it before the session can resume.
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.passphrase_required = true; this.passphrase_required = true;
@@ -280,11 +282,10 @@ impl Backend {
})); }));
} }
/// Decrypt the NIP-49 encrypted credential stored in the keyring with /// Decrypt the NIP-49 keyring credential with the given passphrase.
/// the given passphrase and resume the session. /// Resume the session on success.
/// /// The scrypt decryption runs off the UI thread.
/// The scrypt decryption runs off the UI thread. The task yields the /// The task yields the public key or the failure reason, e.g. a wrong passphrase.
/// public key, or the failure reason (e.g. wrong passphrase).
pub fn restore_with_passphrase( pub fn restore_with_passphrase(
&mut self, &mut self,
password: &str, password: &str,
@@ -319,12 +320,12 @@ impl Backend {
}) })
} }
/// Create a new identity: generate keys, encrypt the secret key with the /// Create a new identity.
/// passphrase (NIP-49) and persist it in the keyring, then publish the /// Generate keys and encrypt the secret key with the passphrase, NIP-49.
/// user's NIP-65 relay list, metadata and grasp list. /// Persist it in the keyring.
/// /// Then publish the NIP-65 relay list, metadata and grasp list.
/// The encryption runs off the UI thread; the task yields the new /// The encryption runs off the UI thread.
/// public key. /// The task yields the new public key.
pub fn create_identity( pub fn create_identity(
&mut self, &mut self,
name: &str, name: &str,
@@ -360,8 +361,7 @@ impl Backend {
write.await?; write.await?;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// Become the new identity, so the publishes below are // Become the new identity so later publishes are signed with the new keys.
// signed with the new keys.
this.signer.swap_inner(keys); this.signer.swap_inner(keys);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx); this.bootstrap_user(public_key, cx);
@@ -412,21 +412,20 @@ impl Backend {
}) })
} }
/// Create a new repository: initialize a local clone with a `main` /// Create a repository.
/// branch and a `README.md`, publish the NIP-34 announcement and the /// Initialize a local clone with a `main` branch and a `README.md`.
/// repository state to the grasp relays, then push the initial commit /// Publish the NIP-34 announcement and the repository state to the grasp relays.
/// to each grasp server. A working copy of the repository is also /// Push the initial commit to each grasp server.
/// created at `<folder>/<name>` (named like the repo header's Clone /// Also create a working copy at `<folder>/<name>`, like the header's Clone action.
/// action), with `origin` pointing at the first grasp server, so the /// Its `origin` points at the first grasp server.
/// new project exists in the chosen folder right away. /// The new project exists in the chosen folder right away.
/// /// The events must reach the grasp relays before the push.
/// The events must reach the grasp servers *before* the push: GRASP /// GRASP servers hold the signed state event in purgatory.
/// servers hold the signed state event in "purgatory" and only accept /// They accept the push only while the authorization is pending.
/// a push for a not-yet-existing repository while that authorization is /// The pushed repository must not exist yet.
/// pending (it expires after 30 minutes), like gitworkshop and ngit. /// The authorization expires after 30 minutes, like gitworkshop and ngit.
/// /// The git work runs on background threads.
/// The git work runs on background threads; the task yields the /// The task yields the announcement and the path of the working copy.
/// published announcement and the path of the created working copy.
pub fn create_repository( pub fn create_repository(
&mut self, &mut self,
name: &str, name: &str,
@@ -454,9 +453,10 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to create a repository"))); return Task::ready(Err(anyhow!("Sign in to create a repository")));
}; };
// The repository identifier is derived from the name, like ngit and // The repository identifier is derived from the name, like ngit and gitworkshop.
// gitworkshop: spaces become hyphens, other non-alphanumeric // Spaces become hyphens.
// characters (except `/`) become hyphens, case is preserved. // Other non-alphanumeric characters become hyphens, except `/`.
// Case is preserved.
let repo_id = identifier_from_name(&name); let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 { if repo_id.is_empty() || repo_id.len() > 100 {
return Task::ready(Err(anyhow!( return Task::ready(Err(anyhow!(
@@ -495,18 +495,16 @@ impl Backend {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
let commit = signed_git::init_repository(&path, &name, &description)?; let commit = signed_git::init_repository(&path, &name, &description)?;
// Point `origin` at the first grasp server so later // Point `origin` at the first grasp server.
// fetches (and pushes) have a target, like ngit. // Later fetches and pushes have a target, like ngit.
if let Some(base) = servers.first().and_then(grasp_base_url) { if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git"); let url = format!("{base}/{owner}/{repo_id}.git");
signed_git::ensure_origin(&path, &url).ok(); signed_git::ensure_origin(&path, &url).ok();
} }
// A working copy at `<folder>/<name>` (the same naming // A working copy at `<folder>/<name>`, like the header's Clone action.
// as the header's Clone action), cloned from the mirror // Cloned from the mirror above so it shares the announced history.
// above so it shares the announced history exactly; // `origin` is set to the first grasp server, not the mirror path.
// `origin` is re-pointed at the first grasp server
// instead of the mirror path.
let destination = { let destination = {
let dir_name = signed_git::sanitize_path_component(&name); let dir_name = signed_git::sanitize_path_component(&name);
let dir_name = if dir_name.is_empty() { let dir_name = if dir_name.is_empty() {
@@ -546,8 +544,8 @@ impl Backend {
this.add_relays(urls, cx); this.add_relays(urls, cx);
})?; })?;
// The state event is the push authorization ("purgatory"), so // The state event is the push authorization.
// it must be accepted before the push below. // It must be accepted before the push below.
let announcement = GitRepositoryAnnouncement { let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(), id: repo_id.clone(),
name: Some(name.clone()), name: Some(name.clone()),
@@ -593,8 +591,7 @@ impl Backend {
} }
}; };
// Push to every grasp server; creation only fails when no // Push to every grasp server. Creation fails only when no server accepted it.
// server accepted it.
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
let owner = owner.clone(); let owner = owner.clone();
@@ -604,8 +601,8 @@ impl Backend {
}); });
if let Err(e) = push.await { if let Err(e) = push.await {
// The events are already published; retract them so the // The events are already published.
// repository doesn't remain announced without content. // Retract them so the repository is not left announced without content.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.retract_events(&[event.clone(), state_event.clone()], cx); this.retract_events(&[event.clone(), state_event.clone()], cx);
}) })
@@ -624,13 +621,12 @@ impl Backend {
}) })
} }
/// Publish an existing local repository to NIP-34: read its current /// Publish an existing local repository to NIP-34.
/// branches, tags and HEAD, publish the announcement and the repository /// Read its current branches, tags and HEAD.
/// state to the grasp relays, then push every branch and tag to each /// Publish the announcement and the repository state to the grasp relays.
/// grasp server. Also points `origin` at the first grasp server. /// Then push every branch and tag to each grasp server.
/// /// Also point `origin` at the first grasp server.
/// Same ordering constraint as [`Self::create_repository`]: the state /// The state event must be accepted before the push, like [`Self::create_repository`].
/// event ("purgatory") must be accepted before the push.
pub fn publish_local_repo( pub fn publish_local_repo(
&mut self, &mut self,
path: PathBuf, path: PathBuf,
@@ -654,8 +650,7 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a repository"))); return Task::ready(Err(anyhow!("Sign in to publish a repository")));
}; };
// The repository identifier is derived from the name as in // The identifier derives from the name, as in [`Self::create_repository`].
// [`Self::create_repository`].
let repo_id = identifier_from_name(&name); let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 { if repo_id.is_empty() || repo_id.len() > 100 {
@@ -690,8 +685,8 @@ impl Backend {
this.add_relays(urls, cx); this.add_relays(urls, cx);
})?; })?;
// The state event is the push authorization ("purgatory"), so // The state event is the push authorization.
// it must be accepted before the push below. // It must be accepted before the push below.
let announcement = GitRepositoryAnnouncement { let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(), id: repo_id.clone(),
name: Some(name.clone()), name: Some(name.clone()),
@@ -735,9 +730,9 @@ impl Backend {
} }
}; };
// Push every branch and tag to each grasp server; the init // Push every branch and tag to each grasp server.
// only fails when no server accepted it. An empty repository // The push fails only when no server accepted it.
// has nothing to push. // An empty repository has nothing to push.
if !refs.is_empty() { if !refs.is_empty() {
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
@@ -759,8 +754,7 @@ impl Backend {
} }
} }
// Point `origin` at the first grasp server so later pushes // Point `origin` at the first grasp server so later pushes have a target.
// have a target.
if let Some(base) = servers.first().and_then(grasp_base_url) { if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git"); let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone(); let path = path.clone();
@@ -774,10 +768,10 @@ impl Backend {
}) })
} }
/// Re-push the repository's current refs to the grasp servers announced /// Re-push the repository's current refs to the grasp servers in its `relays` tag.
/// in its `relays` tag: publishes a fresh state event (the push /// Publish a fresh state event, the push authorization.
/// authorization), then pushes every branch and tag, like the init /// Then push every branch and tag, like the init flow.
/// flow. The repository must have a local clone in the cache. /// The repository must have a local clone in the cache.
pub fn push_repository( pub fn push_repository(
&mut self, &mut self,
announcement: Announcement, announcement: Announcement,
@@ -788,12 +782,12 @@ impl Backend {
self.push_repo_from(announcement, path, None, cx) self.push_repo_from(announcement, path, None, cx)
} }
/// Push the refs of a local checkout (the working copy of the user's /// Push the refs of a local checkout to the grasp servers in its `relays` tag.
/// own repository) to the grasp servers announced in the `relays` tag: /// The checkout is the working copy of the user's own repository.
/// publishes a fresh state event, then pushes every branch and tag of /// Publish a fresh state event, then push every branch and tag of the checkout.
/// the checkout, like the init flow. `announced_head` keeps the state /// That mirrors the init flow.
/// event's `HEAD` on the repository's announced default branch when the /// `announced_head` keeps the state event's `HEAD` on the announced default branch.
/// checkout is on a different branch. /// That matters when the checkout is on a different branch.
pub fn push_checkout( pub fn push_checkout(
&mut self, &mut self,
announcement: Announcement, announcement: Announcement,
@@ -804,12 +798,13 @@ impl Backend {
self.push_repo_from(announcement, checkout, announced_head, cx) self.push_repo_from(announcement, checkout, announced_head, cx)
} }
/// Shared body of the mirror-based and checkout-based pushes: publish /// Shared body of the mirror-based and checkout-based pushes.
/// the repository state (the push authorization), then push every /// Publish the repository state, the push authorization.
/// branch and tag of `path` to each announced grasp server. Pushes are /// Then push every branch and tag of `path` to each announced grasp server.
/// single-flight per repository: two concurrent pushes of the same refs /// Pushes are single-flight per repository.
/// (e.g. two panels of the same repository) make the losing push fail /// Concurrent pushes of the same refs fail server-side.
/// server-side with a compare-and-swap rejection. /// The rejection is a compare-and-swap error from the server.
/// Two panels of the same repository can produce the race.
fn push_repo_from( fn push_repo_from(
&mut self, &mut self,
announcement: Announcement, announcement: Announcement,
@@ -841,8 +836,8 @@ impl Backend {
let relays = announcement.relays.clone(); let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// Held for the whole task; dropped (and the lock released) on // Held for the whole task.
// completion, on error and on cancellation alike. // Dropped on completion, on error and on cancellation alike.
let _guard = guard; let _guard = guard;
let mut state = { let mut state = {
@@ -853,10 +848,9 @@ impl Backend {
work.await? work.await?
}; };
// The state event announces the pushed refs. When the source is // The state event announces the pushed refs.
// a checkout on a side branch, keep the repository's announced // Keep the announced default branch in `HEAD` when it is among the pushed refs.
// default branch (its `HEAD`) when that branch is among the // Otherwise `HEAD` stays the checkout's current branch.
// pushed refs; otherwise the checkout's current branch.
let heads: Vec<&str> = state let heads: Vec<&str> = state
.refs .refs
.iter() .iter()
@@ -895,10 +889,10 @@ impl Backend {
}) })
} }
/// Delete the repository from nostr: publish NIP-09 deletions for its /// Delete the repository from nostr.
/// announcement, state and activity events (issues, pull requests, /// Publish NIP-09 deletions for its announcement, state and activity events.
/// patches, statuses, comments). Only the repository owner may delete /// Those are issues, pull requests, patches, statuses and comments.
/// it. /// Only the repository owner may delete it.
pub fn delete_repository( pub fn delete_repository(
&mut self, &mut self,
addr: RepoAddr, addr: RepoAddr,
@@ -939,8 +933,8 @@ impl Backend {
}) })
} }
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// Login with an `nsec1...` key or a `bunker://...` URI.
/// the credential's prefix. /// Dispatch on the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) { pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
let credential = credential.trim(); let credential = credential.trim();
@@ -955,8 +949,8 @@ impl Backend {
} }
} }
/// Create a fresh identity and login with it. The generated key is /// Create a fresh identity and login with it.
/// persisted in the keyring like any other `nsec` credential. /// The generated key is persisted in the keyring like any other `nsec` credential.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) { pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate() let nsec = Keys::generate()
.secret_key() .secret_key()
@@ -965,8 +959,8 @@ impl Backend {
self.login_with_nsec(&nsec, cx); self.login_with_nsec(&nsec, cx);
} }
/// Login with an `nsec1...` secret key. The credential is verified by /// Login with an `nsec1...` secret key.
/// the signer flow and persisted in the keyring. /// The credential is verified by the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) { pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let keys = match SecretKey::parse(nsec) { let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret), Ok(secret) => Keys::new(secret),
@@ -990,11 +984,11 @@ impl Backend {
})); }));
} }
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is /// Login with a `bunker://...` URI, NIP-46.
/// generated and embedded into the stored URI as `?master=<nsec>`, so /// A fresh session key is embedded into the stored URI as `?master=<nsec>`.
/// no separate keyring entry is needed. The auth URL, if any, is opened /// No separate keyring entry is needed.
/// in the default browser. The credential is persisted in the keyring /// The auth URL, if any, is opened in the default browser.
/// after the signer proves reachable. /// The credential is persisted in the keyring after the signer proves reachable.
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) { pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
let uri_string = uri.trim().to_owned(); let uri_string = uri.trim().to_owned();
@@ -1058,8 +1052,8 @@ impl Backend {
})); }));
} }
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp /// Fetch the user's grasp list of kind `10317`.
/// servers as relays. /// Add the listed grasp servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) { fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1111,8 +1105,8 @@ impl Backend {
self.current_user self.current_user
} }
/// Whether the stored credential is NIP-49 encrypted and a passphrase /// True when the stored credential is NIP-49 encrypted.
/// is still needed to resume the session. /// A passphrase is still needed to resume the session.
pub fn passphrase_required(&self) -> bool { pub fn passphrase_required(&self) -> bool {
self.passphrase_required self.passphrase_required
} }
@@ -1127,13 +1121,15 @@ impl Backend {
self.connected self.connected
} }
/// Progress of the in-flight negentropy sync, if any: `(total, current)`. /// Progress of the in-flight negentropy sync, if any.
/// Reported as `total` and `current`.
pub fn sync_progress(&self) -> Option<(u64, u64)> { pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress self.sync_progress
} }
/// Update the signer (any type implementing the async signer traits, /// Update the signer.
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy). /// Any type implementing the async signer traits works.
/// Examples are `Keys`, `NostrConnect` and a browser extension proxy.
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>) pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
@@ -1194,8 +1190,8 @@ impl Backend {
})); }));
} }
/// Add relays used only for discovery (e.g. NIP-65 indexers) and /// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
/// connect to them. No subscriptions or writes are routed through them. /// No subscriptions or writes are routed through them.
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) { pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1218,8 +1214,9 @@ impl Backend {
})); }));
} }
/// Start a persistent subscription. Matching events are stored in the /// Start a persistent subscription.
/// database automatically and surface as [`BackendEvent::NostrUpdate`]. /// Matching events are stored in the database automatically.
/// They surface as [`BackendEvent::NostrUpdate`].
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) { pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1233,9 +1230,8 @@ impl Backend {
})); }));
} }
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`] /// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
/// and is still recent enough to suppress a duplicate. Records the /// Records the fingerprint when returning `false`, pruning expired entries first.
/// fingerprint (after pruning expired entries) when returning `false`.
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
self.recent_fetches self.recent_fetches
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW); .retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
@@ -1246,17 +1242,13 @@ impl Backend {
false false
} }
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and /// Connect to a repository's announced relays, its NIP-34 `relays` tag.
/// fetch its events from them: a one-shot auto-closing subscription for /// Fetch the repository's events from them.
/// `filters`, plus a negentropy sync so issues, patches and PRs stored /// Run a one-shot auto-closing subscription for `filters`.
/// only on those relays are not missed. /// Then a negentropy sync covers issues, patches and PRs stored only on those relays.
/// /// An identical request within [`FETCH_DEDUP_WINDOW`] is skipped.
/// Deduplicated: an identical request (same relays and filters) started /// The relays stay in the pool, so later publishes for this repository reach them too.
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the /// Failures are logged, not surfaced.
/// same repository doesn't re-run the fetch.
///
/// Best-effort: failures are logged, not surfaced. The relays stay in
/// the pool, so later publishes for this repository also reach them.
pub fn connect_repo_relays( pub fn connect_repo_relays(
&mut self, &mut self,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
@@ -1285,10 +1277,10 @@ impl Backend {
})); }));
} }
/// Start a one-shot subscription targeted only at the bootstrap relays, /// One-shot subscription on the bootstrap relays only.
/// auto-closing after EOSE or a short timeout. Matching events are stored /// Auto-closes after EOSE or a short timeout.
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the /// Matching events are stored in the database.
/// subscription is open. /// They surface as [`BackendEvent::NostrUpdate`] while the subscription is open.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) { pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1303,14 +1295,13 @@ impl Backend {
})); }));
} }
/// Negentropy-sync the given filter against the bootstrap relays: /// Negentropy-sync the given filter against the bootstrap relays.
/// reconciles the local database with the relays in both directions. /// Reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to /// Emits [`BackendEvent::SyncProgress`] while running.
/// whole-percent changes) and [`BackendEvent::Synced`] on completion. /// Throttled to whole-percent changes.
/// /// Emits [`BackendEvent::Synced`] on completion.
/// Deduplicated: an identical sync started within /// An identical sync started within [`FETCH_DEDUP_WINDOW`] is skipped.
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original /// Observers still see the original sync's progress and completion events.
/// sync's progress and completion events.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) { pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
if self.fetch_recently_started(fingerprint) { if self.fetch_recently_started(fingerprint) {
@@ -1385,12 +1376,10 @@ impl Backend {
})); }));
} }
/// Sign, broadcast and locally store an event. Emits /// Sign, broadcast and locally store an event.
/// [`BackendEvent::Published`] on success so stores can refresh. /// Emits [`BackendEvent::Published`] on success so stores can refresh.
/// /// The task yields the outcome of this specific action for inline progress or errors.
/// The task yields the outcome of this specific action (for inline /// The caller owns the task, dropping it cancels the publish.
/// progress/errors) and is owned by the caller; dropping it cancels
/// the publish.
pub fn send( pub fn send(
&mut self, &mut self,
builder: EventBuilder, builder: EventBuilder,
@@ -1400,8 +1389,8 @@ impl Backend {
let signer = self.signer.clone(); let signer = self.signer.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// Sign with the current signer, broadcast, and save locally so // Sign with the current signer, broadcast and save locally.
// the event is immediately visible to database queries. // The event is immediately visible to database queries.
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
let event = builder.finalize_async(&signer).await?; let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).await?; let output = client.send_event(&event).await?;
@@ -1440,10 +1429,10 @@ impl Backend {
}) })
} }
/// Broadcast and locally store an already-signed event, like /// Broadcast and locally store an already-signed event.
/// [`Self::send`] without the signing step. Callers that signed early /// Like [`Self::send`] without the signing step.
/// (e.g. to learn the event id before pushing a commit to the grasp /// Callers that signed early use this.
/// servers) publish through this. /// They may need the event id before pushing a commit to the grasp servers.
pub fn publish_event( pub fn publish_event(
&mut self, &mut self,
event: Event, event: Event,
@@ -1489,9 +1478,9 @@ impl Backend {
}) })
} }
/// Publish a NIP-34 repository announcement (kind 30617) with the /// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
/// current signer. The returned task yields the published event, so /// The returned task yields the published event.
/// callers can show inline progress/errors. /// Callers can show inline progress or errors.
pub fn publish_announcement( pub fn publish_announcement(
&mut self, &mut self,
announcement: GitRepositoryAnnouncement, announcement: GitRepositoryAnnouncement,
@@ -1500,9 +1489,9 @@ impl Backend {
self.send(announcement.into_event_builder(), cx) self.send(announcement.into_event_builder(), cx)
} }
/// Sign, broadcast and store an event without awaiting the result; /// Sign, broadcast and store an event without awaiting the result.
/// failures surface through [`BackendEvent::Error`]. The spawned task is /// Failures surface through [`BackendEvent::Error`].
/// owned by the backend, so it is cancelled when the backend is dropped. /// The backend owns the spawned task, so dropping it cancels the task.
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) { fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let task = self.send(builder, cx); let task = self.send(builder, cx);
@@ -1517,10 +1506,10 @@ impl Backend {
})); }));
} }
/// Publish a NIP-09 deletion event for `events` (best-effort), so a /// Publish NIP-09 deletions for `events`, best-effort.
/// publish that fails midway can retract the events that were already /// A publish that fails midway retracts the events already broadcast to relays.
/// broadcast to relays. Failures are logged, not surfaced: the caller's /// Failures are logged, not surfaced.
/// error already told the user what happened. /// The caller's error already told the user what happened.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) { fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
if events.is_empty() { if events.is_empty() {
return; return;
@@ -1544,8 +1533,8 @@ impl Backend {
} }
} }
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and /// Fingerprint of a relay and filter set, for fetch dedup.
/// filters are sorted first so the fingerprint is order-independent. /// Relays and filters are sorted first, so the fingerprint is order-independent.
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
let mut relays: Vec<&str> = relays.to_vec(); let mut relays: Vec<&str> = relays.to_vec();
relays.sort_unstable(); relays.sort_unstable();
@@ -1558,11 +1547,10 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
hasher.finish() hasher.finish()
} }
/// Add the given relays, connect to them, and fetch the filters: a one-shot /// Add the given relays, connect and fetch the filters.
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter /// Run a one-shot subscription, auto-closing after EOSE, then a negentropy sync per filter.
/// as a second pass, so events that race with the subscription or relays /// The second pass catches events that race the subscription or flaky EOSE behavior.
/// with flaky EOSE behavior can't be missed. Relays without NEG-XX support /// Relays without NEG-XX support fail the sync step, the subscription already covered them.
/// just fail the sync step; the subscription already covered them.
async fn connect_repo_relays_only( async fn connect_repo_relays_only(
client: &Client, client: &Client,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
@@ -1576,8 +1564,8 @@ async fn connect_repo_relays_only(
for url in &relays { for url in &relays {
added |= client.add_relay(url).await?; added |= client.add_relay(url).await?;
} }
// Connecting is only needed when the pool grew; connected relays no-op, // Connect only when the pool grew.
// but the call still iterates every relay in the pool. // Connected relays no-op, but the call still iterates every relay in the pool.
if added { if added {
client.connect().await; client.connect().await;
} }
@@ -1592,9 +1580,9 @@ async fn connect_repo_relays_only(
.collect(); .collect();
client.subscribe(target).close_on(opts).await?; client.subscribe(target).close_on(opts).await?;
// Sync the filters concurrently: each reconciles against every relay // Sync the filters concurrently.
// either way, and a relay without NEG-XX support otherwise serializes // Each reconciles against every relay either way.
// its initial timeout behind every other filter. // Without NEG-XX a relay would serialize its initial timeout behind every other filter.
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
let syncs = filters.into_iter().map(|filter| { let syncs = filters.into_iter().map(|filter| {
let client = &client; let client = &client;
@@ -1616,9 +1604,10 @@ async fn connect_repo_relays_only(
Ok(()) Ok(())
} }
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a /// Subscribe only on the bootstrap relays.
/// short timeout. Use for one-shot data fetches (repo events, profiles) /// Auto-closes after EOSE or a short timeout.
/// instead of persistent gossip-routed subscriptions. /// Use for one-shot data fetches, repo events and profiles.
/// Not for persistent gossip-routed subscriptions.
pub(crate) async fn subscribe_bootstrap_only( pub(crate) async fn subscribe_bootstrap_only(
client: &Client, client: &Client,
filters: Vec<Filter>, filters: Vec<Filter>,
@@ -1658,17 +1647,17 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
format!("{uri}{separator}master={nsec}") format!("{uri}{separator}master={nsec}")
} }
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like /// Base URL of a grasp server, `https://<host>`.
/// ngit) base URL for a grasp server. The repository then lives at /// `ws://` grasp servers use `http://<host>`, like ngit.
/// `{base}/{npub}/{repo-id}.git`. /// The repository then lives at `{base}/{npub}/{repo-id}.git`.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> { pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `domain()` drops the port; parse the full URL to keep it (local dev // `domain()` drops the port.
// grasp servers commonly run on a custom port). // Parse the full URL to keep it, local dev grasp servers often run on a custom port.
let parsed = Url::parse(relay.as_str()).ok()?; let parsed = Url::parse(relay.as_str()).ok()?;
let host = parsed.host_str()?; let host = parsed.host_str()?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
// `ws://` grasp servers (e.g. local dev relays) speak plain HTTP; // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
// everything else is HTTPS, matching ngit. // Everything else is HTTPS, matching ngit.
let scheme = if relay.scheme().is_secure() { let scheme = if relay.scheme().is_secure() {
"https" "https"
} else { } else {
@@ -1677,25 +1666,25 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
Some(format!("{scheme}://{host}{port}")) Some(format!("{scheme}://{host}{port}"))
} }
/// The GRASP clone URL of a repository on a grasp server, matching the /// GRASP clone URL of a repository on a grasp server.
/// format ngit announces: `https://<host>/<npub>/<repo-id>.git`. /// Matches the format ngit announces, `https://<host>/<npub>/<repo-id>.git`.
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> { fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
let base = grasp_base_url(relay)?; let base = grasp_base_url(relay)?;
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
} }
/// The GRASP-06 contributor namespace URL of a pull request tip on the /// GRASP-06 contributor namespace URL of a pull request tip.
/// author's grasp server: `{base}/prs/<author-npub>/<repo-id>.git` (npub in /// The pattern is `{base}/prs/<author-npub>/<repo-id>.git`.
/// the URL; the server stores it under the hex form). Anyone may push there; /// The npub sits in the URL, the server stores it under the hex form.
/// no announcement or maintainer rights are involved. /// Anyone may push there, no announcement or maintainer rights are involved.
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
format!("{base_url}/prs/{npub}/{repo_id}.git") format!("{base_url}/prs/{npub}/{repo_id}.git")
} }
/// Assemble the `clone` URLs of a pull request: the author's GRASP-06 /// Assemble the `clone` URLs of a pull request.
/// `/prs/` URLs first (author-controlled, most likely to accept the tip /// The author's GRASP-06 `/prs/` URLs come first.
/// push), then the base announcement's clone URLs, deduplicated while /// They are author-controlled and most likely to accept the tip push.
/// preserving that order. /// The base announcement's clone URLs follow, deduplicated while preserving order.
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> { pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut urls = Vec::new(); let mut urls = Vec::new();
@@ -1708,7 +1697,7 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Ve
} }
/// The `g` tag servers of one kind-10317 grasp list event, in tag order. /// The `g` tag servers of one kind-10317 grasp list event, in tag order.
/// Unparseable URLs are dropped (the UI only writes well-formed servers). /// Unparseable URLs are dropped, the UI only writes well-formed servers.
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> { fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
event event
.tags .tags
@@ -1719,10 +1708,9 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
.collect() .collect()
} }
/// The grasp servers of the newest kind-10317 grasp list among `events` /// Grasp servers of the newest kind-10317 grasp list among `events`.
/// (latest event wins, like every other latest-wins resolution in the app); /// The latest event wins, like other latest-wins resolutions in the app.
/// empty when there is no list, so the caller falls back to the settings /// Empty when there is no list, so the caller falls back to the settings defaults.
/// defaults.
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> { fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
events events
.into_iter() .into_iter()
@@ -1731,10 +1719,10 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
.unwrap_or_default() .unwrap_or_default()
} }
/// Resolve the user's published grasp servers: the `g` tags (in order) of /// Resolve the user's published grasp servers.
/// their latest kind-10317 grasp list in the local database. Returns an /// Read the `g` tags of their latest kind-10317 grasp list in the local database.
/// empty list when the user has no published list, so the caller can fall /// Returns an empty list when the user has no published list.
/// back to the settings defaults. /// The caller can then fall back to the settings defaults.
pub(crate) async fn user_grasp_list_servers( pub(crate) async fn user_grasp_list_servers(
client: Client, client: Client,
user: PublicKey, user: PublicKey,
@@ -1748,11 +1736,11 @@ pub(crate) async fn user_grasp_list_servers(
Ok(latest_grasp_list_servers(events)) Ok(latest_grasp_list_servers(events))
} }
/// Push the repository at `path` to every grasp server: a server that /// Push the repository at `path` to every grasp server.
/// rejects the push is logged, but the push only fails when no server /// Rejecting servers are logged, the push only fails when no server accepted it.
/// accepted it. `push` performs the single-server push (e.g. /// `push` performs the single-server push.
/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`] /// [`signed_git::push_main`] serves the create flow.
/// for the init flow). /// [`signed_git::push_all`] serves the init flow.
async fn push_to_grasp_servers( async fn push_to_grasp_servers(
path: PathBuf, path: PathBuf,
owner: String, owner: String,
@@ -1789,7 +1777,7 @@ async fn push_to_grasp_servers(
} }
/// Split a stored bunker credential into the plain URI and the session key. /// Split a stored bunker credential into the plain URI and the session key.
/// Credentials without an embedded key (legacy) get a fresh one. /// Credentials without an embedded key, legacy, get a fresh one.
fn extract_master_key(credential: &str) -> (&str, Keys) { fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") { match credential.split_once("master=") {
Some((base, nsec)) => { Some((base, nsec)) => {
@@ -1837,7 +1825,7 @@ mod tests {
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"), grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
"https://relay.ngit.dev/prs/npub1author/my-repo.git" "https://relay.ngit.dev/prs/npub1author/my-repo.git"
); );
// `ws://` grasp servers (local dev) keep their plain-HTTP base. // `ws://` grasp servers, local dev, keep their plain-HTTP base.
assert_eq!( assert_eq!(
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"), grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
"http://localhost:8080/prs/npub1author/my-repo.git" "http://localhost:8080/prs/npub1author/my-repo.git"
@@ -1913,7 +1901,7 @@ mod tests {
vec!["wss://fresh.example", "wss://also.example"] vec!["wss://fresh.example", "wss://also.example"]
); );
// No list at all: empty, so the caller falls back to the defaults. // No list at all, empty, so the caller falls back to the defaults.
assert!(latest_grasp_list_servers(Vec::new()).is_empty()); assert!(latest_grasp_list_servers(Vec::new()).is_empty());
} }
} }
+126 -149
View File
@@ -1,28 +1,3 @@
//! Local checkout associations ("remember" tier of the PR suggestions):
//! which local folders are checkouts of which announced repositories.
//!
//! Two sources feed the resolution:
//!
//! - **Remembered records** (settings, [`settings::CheckoutRecord`]):
//! recorded when the user clones a repository from the app or picks a
//! folder in the New PR panel.
//! - **Implicit matches** over the local scan ([`LocalReposStore`]): a
//! scanned repository whose `origin` URL matches an announcement `clone`
//! URL (scheme-insensitive), or whose root commit equals an announcement
//! EUC, is a checkout of that announced repository.
//!
//! The store also computes per-checkout statuses for two surfaces:
//!
//! - **"Ready to contribute"** (pull-request banner of repositories the
//! user does not own): branch, base and commits ahead of the base.
//! - **"Ready to push"** (sidebar badge and banner of the user's own
//! repositories): the checked-out branch has commits the grasp servers
//! do not have yet (counted against the refreshed remote-tracking
//! refs), so the user can push their local work from the app.
//!
//! Everything is resolved on background threads and swapped in as
//! [`Arc`]s; the UI never waits for git.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -40,18 +15,17 @@ use crate::git_store::GitStore;
use crate::local_repos::LocalReposStore; use crate::local_repos::LocalReposStore;
use crate::repo_list::RepoListStore; use crate::repo_list::RepoListStore;
/// Delay between a refresh request and the actual re-computation, so bursts /// Delay between a refresh request and the actual re-computation.
/// of notifications (settings edits, rescan ticks) collapse into one pass. /// Bursts of notifications, settings edits and rescan ticks, collapse into one pass.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How often the statuses of open repository panels are refreshed, so a /// How often the statuses of open repository panels are refreshed.
/// checkout committed to or pulled in external git surfaces in the banner /// A commit or pull in external git surfaces in the banner without reopening the panel.
/// without reopening the panel.
const STATUS_POLL: Duration = Duration::from_secs(15); const STATUS_POLL: Duration = Duration::from_secs(15);
/// Background poll interval for the "ready to push" badges of the user's /// Background poll interval for the `ready to push` badges of the user's own repositories.
/// own repositories when no repository panel is open (each cycle refreshes /// Used when no repository panel is open.
/// the remote view of the checkouts with a git fetch). /// Each cycle refreshes the remote view of the checkouts with a git fetch.
const PUSH_POLL: Duration = Duration::from_secs(60); const PUSH_POLL: Duration = Duration::from_secs(60);
/// Maximum checkouts considered per repository when computing statuses. /// Maximum checkouts considered per repository when computing statuses.
@@ -61,24 +35,25 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
impl Global for GlobalCheckoutsStore {} impl Global for GlobalCheckoutsStore {}
/// One associated local checkout of a repository, with the git facts needed /// One associated local checkout of a repository.
/// to suggest a pull request. /// Carries the git facts needed to suggest a pull request.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStatus { pub struct CheckoutStatus {
/// The checkout folder. /// The checkout folder.
pub path: PathBuf, pub path: PathBuf,
/// The branch checked out (`None`-less: detached checkouts are idle). /// The branch checked out. A detached checkout is idle and yields no status.
pub branch: String, pub branch: String,
/// Commit the branch points at, for tip-based PR dedupe. /// Commit the branch points at, for tip-based PR dedupe.
pub head: String, pub head: String,
/// What the branch is compared against. For ready-to-contribute /// What the branch is compared against.
/// statuses: the announced HEAD branch (else `main`, else the first /// For ready-to-contribute statuses, the announced HEAD branch.
/// local branch). For ready-to-push statuses: the remote-tracking ref /// The fallbacks are `main`, then the first local branch.
/// the unpushed commits are counted against /// For ready-to-push statuses, the remote-tracking ref.
/// (`refs/remotes/origin/<branch>`, or `origin/HEAD` for branches the /// Unpushed commits are counted against it.
/// remote does not have yet). /// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
pub base: String, pub base: String,
/// Commits in `base..branch`; always > 0 (even checkouts are dropped). /// Commits in `base..branch`.
/// Zero-ahead checkouts are dropped, so this is always above zero.
pub ahead: u32, pub ahead: u32,
} }
@@ -91,23 +66,23 @@ struct Remembered {
/// Global store of local-checkout associations and per-checkout statuses. /// Global store of local-checkout associations and per-checkout statuses.
pub struct CheckoutsStore { pub struct CheckoutsStore {
/// Checkout paths per announced repository: remembered records /// Checkout paths per announced repository.
/// (freshest first) plus scanned repos matched implicitly, deduplicated /// Remembered records, freshest first, plus scanned repos matched implicitly.
/// by path. Missing directories are dropped before publishing. /// Deduplicated by path.
/// Missing directories are dropped before publishing.
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>, by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
/// Ready-to-contribute statuses of the requested repositories. /// Ready-to-contribute statuses of the requested repositories.
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>, statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
/// Repositories whose statuses are recomputed whenever the inputs /// Repositories whose statuses are recomputed on every input change.
/// change (the repository detail panels currently open). /// Those are the repository detail panels currently open.
status_requested: HashSet<RepoAddr>, status_requested: HashSet<RepoAddr>,
/// Repositories whose "ready to push" statuses are recomputed on the /// Repositories whose `ready to push` statuses are recomputed on the same cycle.
/// same cycle (the sidebar rows of the user's own repositories, plus /// The sidebar rows of the user's own repositories and their detail panels.
/// the detail panels of those repositories).
push_requested: HashSet<RepoAddr>, push_requested: HashSet<RepoAddr>,
/// Ready-to-push statuses of the requested own repositories. /// Ready-to-push statuses of the requested own repositories.
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>, push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
/// Announced head branch last provided per requested repository, so a /// Last announced head branch per requested repository.
/// recompute defaults the base the same way. /// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>, requested_head: HashMap<RepoAddr, Option<String>>,
refreshing: bool, refreshing: bool,
refresh_dirty: bool, refresh_dirty: bool,
@@ -127,9 +102,10 @@ impl CheckoutsStore {
cx.set_global(GlobalCheckoutsStore(entity)); cx.set_global(GlobalCheckoutsStore(entity));
} }
/// Create the store: observe the inputs (settings records, the local /// Create the store.
/// scan, the announcement list, signer changes) and resolve the /// Observe the inputs, settings records, the local scan and the announcement list.
/// associations right away. /// Signer changes also trigger a refresh.
/// Associations are resolved right away.
pub fn new(cx: &mut Context<Self>) -> Self { pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new(); let mut subscriptions = Vec::new();
@@ -148,8 +124,8 @@ impl CheckoutsStore {
subscriptions.push(cx.observe(&repos, |this, _repos, cx| { subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.refresh(cx); this.refresh(cx);
})); }));
// Another identity's repositories must not keep the previous // Another identity's repositories must not keep the old statuses alive.
// user's statuses (or polls) alive. // Their polls stop too.
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
if matches!(event, BackendEvent::SignerChanged) { if matches!(event, BackendEvent::SignerChanged) {
this.status_requested.clear(); this.status_requested.clear();
@@ -182,8 +158,9 @@ impl CheckoutsStore {
store store
} }
/// Remember a successful local-checkout use: (re)insert the record with /// Remember a successful local-checkout use.
/// a fresh timestamp, so freshest-first ordering follows actual use. /// Re-insert the record with a fresh timestamp.
/// Freshest-first ordering then follows actual use.
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) { pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
return; return;
@@ -212,16 +189,15 @@ impl CheckoutsStore {
}); });
} }
/// The associated checkouts of `addr`, freshest first. Empty when none /// The associated checkouts of `addr`, freshest first.
/// are known (or the resolution has not run yet). /// Empty when none are known or the resolution has not run yet.
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> { pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
self.by_repo.get(addr).cloned().unwrap_or_default() self.by_repo.get(addr).cloned().unwrap_or_default()
} }
/// Ask for the "ready to contribute" statuses of `addr` to be kept /// Ask for the `ready to contribute` statuses of `addr` to stay current.
/// current (called while the repository's detail panel is open). /// Called while the repository's detail panel is open.
/// `announced_head` is the announced HEAD branch of the repository /// `announced_head` is the announced HEAD branch, used to default the base.
/// (from its state announcement), used to default the base.
pub fn request_statuses( pub fn request_statuses(
&mut self, &mut self,
addr: &RepoAddr, addr: &RepoAddr,
@@ -235,33 +211,32 @@ impl CheckoutsStore {
self.refresh(cx); self.refresh(cx);
} }
/// The ready-to-contribute statuses of `addr`; empty while none are /// The ready-to-contribute statuses of `addr`.
/// known or nothing is ahead. /// Empty while none are known or nothing is ahead.
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> { pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.statuses.get(addr).cloned().unwrap_or_default() self.statuses.get(addr).cloned().unwrap_or_default()
} }
/// Ask for the "ready to push" statuses of `addr` to be kept current /// Ask for the `ready to push` statuses of `addr` to stay current.
/// (called by the sidebar for the signed-in user's own repositories and /// The sidebar and the detail panels call this for the user's own repositories.
/// by the detail panels of those repositories). Recomputed on every /// Recomputed on every input change and on a background poll.
/// input change and on a background poll; each cycle refreshes the /// Each cycle refreshes the remote view first.
/// remote view of the checkouts first, so a commit made in external /// A commit made in external git surfaces within one poll interval.
/// git surfaces within one poll interval.
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) { pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
self.push_requested.insert(addr.clone()); self.push_requested.insert(addr.clone());
self.refresh(cx); self.refresh(cx);
} }
/// The ready-to-push statuses of `addr` (only meaningful for /// The ready-to-push statuses of `addr`.
/// repositories announced by the signed-in user); empty while none are /// Only meaningful for repositories announced by the signed-in user.
/// known or nothing is unpushed. /// Empty while none are known or nothing is unpushed.
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> { pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.push_statuses.get(addr).cloned().unwrap_or_default() self.push_statuses.get(addr).cloned().unwrap_or_default()
} }
/// Re-resolve associations (and the requested statuses). Debounced: /// Re-resolve the associations and the requested statuses.
/// bursts of notifications collapse into one pass; requests arriving /// Debounced, bursts of notifications collapse into one pass.
/// while a pass runs are folded into a follow-up. /// Requests arriving while a pass runs fold into a follow-up.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing { if self.refreshing {
self.refresh_dirty = true; self.refresh_dirty = true;
@@ -284,7 +259,7 @@ impl CheckoutsStore {
self.tasks.push(task); self.tasks.push(task);
} }
/// One resolve + apply cycle (debounced entry point). /// One resolve and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) { fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true; self.refreshing = true;
@@ -321,12 +296,12 @@ impl CheckoutsStore {
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty(); let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
// Read the git facts of every scanned repository off the main // Read the git facts of every scanned repository off the main thread.
// thread: origin URL and root commit (both CLI reads). // The facts are the origin URL and the root commit, both CLI reads.
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new(); let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
for path in scanned.iter() { for path in scanned.iter() {
// The browser's mirror clones share the announce URLs and // The browser's mirror clones share the announce URLs and EUCs.
// EUCs; they are not user checkouts. // They are not user checkouts.
if cache_root if cache_root
.as_ref() .as_ref()
.is_some_and(|root| path.starts_with(root)) .is_some_and(|root| path.starts_with(root))
@@ -339,7 +314,7 @@ impl CheckoutsStore {
} }
let associations = resolve_associations(&remembered, &facts, announcements.iter()); let associations = resolve_associations(&remembered, &facts, announcements.iter());
// Missing directories are stale records; drop them. // Missing directories are stale records, drop them.
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
.into_iter() .into_iter()
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect())) .map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
@@ -382,7 +357,7 @@ impl CheckoutsStore {
let (associations, statuses, push_statuses) = match work.await { let (associations, statuses, push_statuses) = match work.await {
Ok(results) => results, Ok(results) => results,
Err(_) => { Err(_) => {
// Git reads are best-effort; keep the last results. // Git reads are best-effort, keep the last results.
return this.update(cx, |this, _cx| { return this.update(cx, |this, _cx| {
this.refreshing = false; this.refreshing = false;
}); });
@@ -408,16 +383,16 @@ impl CheckoutsStore {
this.update(cx, |this, cx| this.refresh(cx))?; this.update(cx, |this, cx| this.refresh(cx))?;
} }
// While any repository panel is open (or any of the user's own // Keep the statuses current while any repository panel is open.
// repositories is watched for the sidebar badge), keep the // The user's own repositories also count when watched for the sidebar badge.
// statuses current: local commits, pulls and branch switches // Local commits, pulls and branch switches happen outside the app.
// happen outside the app and are not otherwise observable. // They are not otherwise observable.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
if poll && !this.debouncing && !this.refreshing { if poll && !this.debouncing && !this.refreshing {
this.debouncing = true; this.debouncing = true;
// Open panels get the fast cadence; the sidebar badges // Open panels get the fast cadence.
// alone poll less aggressively (each cycle fetches // Sidebar-only badges poll less aggressively.
// every watched checkout's remote). // Each cycle fetches every watched checkout's remote.
let delay = if this.status_requested.is_empty() { let delay = if this.status_requested.is_empty() {
PUSH_POLL PUSH_POLL
} else { } else {
@@ -439,11 +414,11 @@ impl CheckoutsStore {
} }
} }
/// The identity of a repository URL: host, explicit port and path with a /// Identity of a repository URL.
/// trailing `.git` (and slashes) stripped. Scheme-insensitive, so /// Host, explicit port and path count, with a trailing `.git` and slashes stripped.
/// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same /// Scheme-insensitive, so `ws`, `wss`, `http`, `https` and `grasp` are one transport.
/// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style /// `None` for unparseable URLs, e.g. `git@`-style or plain paths.
/// or plain paths), which then compare by raw string. /// Those then compare by raw string.
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> { fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
let parsed = Url::parse(url).ok()?; let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase(); let host = parsed.host_str()?.to_ascii_lowercase();
@@ -454,8 +429,8 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
Some((host, parsed.port(), path)) Some((host, parsed.port(), path))
} }
/// Whether two repository URLs point at the same repository, ignoring the /// Whether two repository URLs point at the same repository.
/// transport scheme (see [`url_identity`]). /// Ignores the transport scheme, see [`url_identity`].
fn same_repo_url(a: &str, b: &str) -> bool { fn same_repo_url(a: &str, b: &str) -> bool {
match (url_identity(a), url_identity(b)) { match (url_identity(a), url_identity(b)) {
(Some(a), Some(b)) => a == b, (Some(a), Some(b)) => a == b,
@@ -463,10 +438,10 @@ fn same_repo_url(a: &str, b: &str) -> bool {
} }
} }
/// Resolve the associations between local checkouts and announced /// Resolve the associations between local checkouts and announced repositories.
/// repositories: remembered records (freshest first per repository), /// Remembered records come first, freshest first per repository.
/// followed by scanned repositories matched by origin URL or EUC. /// Scanned repositories matched by origin URL or EUC follow.
/// Deduplicated by path, keeping the first (remembered) occurrence. /// Deduplicated by path, remembered entries win.
fn resolve_associations<'a>( fn resolve_associations<'a>(
remembered: &[Remembered], remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)], scanned: &[(PathBuf, Option<String>, Option<String>)],
@@ -507,8 +482,9 @@ fn resolve_associations<'a>(
out out
} }
/// Whether the worktree of `path` has uncommitted changes (a dirty /// Whether the worktree of `path` has uncommitted changes.
/// checkout is never suggested: the proposal should cover committed work). /// A dirty checkout is never suggested.
/// The proposal should cover committed work.
fn worktree_dirty(path: &Path) -> bool { fn worktree_dirty(path: &Path) -> bool {
let output = Command::new("git") let output = Command::new("git")
.arg("-C") .arg("-C")
@@ -522,8 +498,9 @@ fn worktree_dirty(path: &Path) -> bool {
} }
} }
/// Commits in `base..branch` of the checkout at `path` (`git rev-list /// Commits in `base..branch` of the checkout at `path`.
/// --count`); `0` when the range is empty or cannot be computed. /// Reads `git rev-list --count`.
/// `0` when the range is empty or cannot be computed.
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 { fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
let output = Command::new("git") let output = Command::new("git")
.arg("-C") .arg("-C")
@@ -540,8 +517,8 @@ fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
} }
} }
/// The branch checked out at `path` (`git branch --show-current`), `None` /// The branch checked out at `path`, read via `git branch --show-current`.
/// when detached. /// `None` when detached.
fn current_branch_of(path: &Path) -> Option<String> { fn current_branch_of(path: &Path) -> Option<String> {
let output = Command::new("git") let output = Command::new("git")
.arg("-C") .arg("-C")
@@ -554,10 +531,11 @@ fn current_branch_of(path: &Path) -> Option<String> {
(!branch.is_empty()).then_some(branch) (!branch.is_empty()).then_some(branch)
} }
/// The ready-to-contribute status of one checkout, or `None` when it is /// The ready-to-contribute status of one checkout.
/// idle: detached HEAD, no branches, a dirty worktree, or nothing ahead of /// `None` when idle.
/// its base. The base defaults like the New PR panel: the announced HEAD /// Idle means detached HEAD, no branches, a dirty worktree or nothing ahead of its base.
/// branch when the checkout has it, else `main`, else the first branch. /// The base defaults like the New PR panel.
/// The announced HEAD branch when the checkout has it, else `main`, else the first branch.
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> { fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
let branches = signed_git::worktree_branches(path).ok()?; let branches = signed_git::worktree_branches(path).ok()?;
if branches.is_empty() || worktree_dirty(path) { if branches.is_empty() || worktree_dirty(path) {
@@ -583,8 +561,8 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
}) })
} }
/// Whether the reference `name` (e.g. `refs/remotes/origin/main`) exists /// Whether the reference `name` exists in the checkout at `path`.
/// in the checkout at `path`. /// Example, `refs/remotes/origin/main`.
fn ref_exists(path: &Path, name: &str) -> bool { fn ref_exists(path: &Path, name: &str) -> bool {
let output = Command::new("git") let output = Command::new("git")
.arg("-C") .arg("-C")
@@ -595,13 +573,12 @@ fn ref_exists(path: &Path, name: &str) -> bool {
matches!(output, Ok(output) if output.status.success()) matches!(output, Ok(output) if output.status.success())
} }
/// The "ready to push" status of one checkout of the user's own /// The `ready to push` status of one checkout of the user's own repository.
/// repository: the checked-out branch has commits the grasp servers do not /// The checked-out branch has commits the grasp servers do not have yet.
/// have yet. The remote view is refreshed first (best-effort: offline, the /// The remote view is refreshed first, best-effort.
/// last known remote state still counts the commits made since). Detached /// Offline, the last known remote state still counts commits made since.
/// checkouts, dirty worktrees and branches with no remote state at all /// Detached checkouts, dirty worktrees and an unknown remote state yield no status.
/// (the remote HEAD is unknown) are never suggested; branches the remote /// Branches the remote does not have yet are counted against the remote HEAD.
/// does not have yet are counted against the remote HEAD.
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> { fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
if worktree_dirty(path) { if worktree_dirty(path) {
return None; return None;
@@ -610,13 +587,13 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
let head = signed_git::head_commit_id(path).ok().flatten()?; let head = signed_git::head_commit_id(path).ok().flatten()?;
let origin = signed_git::origin_url(path).ok().flatten()?; let origin = signed_git::origin_url(path).ok().flatten()?;
// Refresh the remote heads so a commit made elsewhere (or pushed from // Refresh the remote heads first.
// another machine) does not show as "to push" forever. // Commits made elsewhere or pushed from another machine must not linger as `to push`.
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok(); signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
let remote = format!("refs/remotes/origin/{branch}"); let remote = format!("refs/remotes/origin/{branch}");
// A branch that has never been fetched/pushed yet is compared against // A branch never fetched or pushed yet compares against the remote HEAD.
// the remote HEAD (its fork point in practice). // The remote HEAD is the fork point in practice.
let base = if ref_exists(path, &remote) { let base = if ref_exists(path, &remote) {
remote remote
} else if ref_exists(path, "refs/remotes/origin/HEAD") { } else if ref_exists(path, "refs/remotes/origin/HEAD") {
@@ -634,10 +611,10 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
}) })
} }
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the /// Whether the pull request `pr` already proposes the same change as `checkout`.
/// caller) already proposes the same change as `checkout`: authored by /// `pr` is a kind-1618 root, resolved `open` by the caller.
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a /// Matches when authored by `user` with a matching `branch-name` tag.
/// `c` tip tag matching the checkout's HEAD commit. /// For renamed branches, a `c` tip tag matching the checkout's HEAD commit counts.
pub fn pr_proposes_checkout( pub fn pr_proposes_checkout(
pr: &Event, pr: &Event,
open: bool, open: bool,
@@ -724,8 +701,8 @@ mod tests {
repo_addr(owner(), id) repo_addr(owner(), id)
} }
/// Build one announcement by the fixed test owner with `clone` URLs and /// Build one announcement by the fixed test owner.
/// an EUC. /// Takes `clone` URLs and an EUC.
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement { fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret")); let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")]; let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
@@ -807,8 +784,8 @@ mod tests {
)]; )];
let base = addr("repo"); let base = addr("repo");
// The same path is both remembered and scanned (its origin matches); // The same path is both remembered and scanned, its origin matches.
// the remembered occurrence wins and it is listed once. // The remembered occurrence wins and the path is listed once.
let resolved = resolve_associations( let resolved = resolve_associations(
&[remembered("/shared", "repo", 100)], &[remembered("/shared", "repo", 100)],
&[ &[
@@ -848,7 +825,7 @@ mod tests {
run(&["commit", "-m", message]); run(&["commit", "-m", message]);
}; };
// A feature branch ahead of main: ready to contribute. // A feature branch ahead of main, ready to contribute.
run(&["checkout", "-b", "feature"]); run(&["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "x\n").expect("write"); std::fs::write(path.join("feature.txt"), "x\n").expect("write");
commit("feature work"); commit("feature work");
@@ -863,17 +840,17 @@ mod tests {
assert!(checkout_status(&path, Some("main")).is_none()); assert!(checkout_status(&path, Some("main")).is_none());
run(&["checkout", "--", "."]); run(&["checkout", "--", "."]);
// Even with main: nothing to propose. // Even on main, nothing to propose.
run(&["checkout", "main"]); run(&["checkout", "main"]);
assert_eq!(checkout_status(&path, Some("main")), None); assert_eq!(checkout_status(&path, Some("main")), None);
} }
#[test] #[test]
fn checkout_push_status_counts_unpushed_commits_only() { fn checkout_push_status_counts_unpushed_commits_only() {
// The "grasp remote": a plain repository the checkout clones from // The `grasp remote` is a plain repository the checkout clones from.
// (origin URL = local path, so the whole cycle runs offline). Git // Its origin URL is a local path, so the whole cycle runs offline.
// refuses pushes to its checked-out branch by default; act like a // Git refuses pushes to a checked-out branch by default.
// grasp server and allow them. // Act like a grasp server and allow them.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let remote = dir.path().join("remote"); let remote = dir.path().join("remote");
signed_git::init_repository(&remote, "My Repo", "").expect("init"); signed_git::init_repository(&remote, "My Repo", "").expect("init");
@@ -913,7 +890,7 @@ mod tests {
// A fresh clone has nothing to push. // A fresh clone has nothing to push.
assert_eq!(checkout_push_status(&checkout), None); assert_eq!(checkout_push_status(&checkout), None);
// One local commit: ready to push, counted against the remote. // One local commit, ready to push, counted against the remote.
std::fs::write(checkout.join("work.txt"), "x\n").expect("write"); std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
run(&["add", "-A"]); run(&["add", "-A"]);
run(&["commit", "-m", "local work"]); run(&["commit", "-m", "local work"]);
@@ -923,12 +900,12 @@ mod tests {
assert_eq!(status.ahead, 1); assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40); assert_eq!(status.head.len(), 40);
// After the push the same commit is on the remote: idle again. // After the push the same commit is on the remote, idle again.
run(&["push", "origin", "main"]); run(&["push", "origin", "main"]);
assert_eq!(checkout_push_status(&checkout), None); assert_eq!(checkout_push_status(&checkout), None);
// A commit made by someone else on the remote must not count as // A commit made by someone else on the remote must not count as local work.
// local work (it is behind, not ahead). // It is behind, not ahead.
let remote_run = |args: &[&str]| { let remote_run = |args: &[&str]| {
let status = Command::new("git") let status = Command::new("git")
.current_dir(&remote) .current_dir(&remote)
@@ -979,15 +956,15 @@ mod tests {
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c"); let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// Without the branch name (renamed), the `c` tip still matches. // Without a branch-name tag, the `c` tip still matches for a renamed branch.
let pr = pr_event( let pr = pr_event(
author, author,
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]], &[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
); );
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// Someone else's PR, a closed PR, a different branch and a missing // Someone else's PR, a closed PR, a different branch and a missing tip.
// tip all leave the checkout uncovered. // They all leave the checkout uncovered.
let pr = pr_event(author, &[&["branch-name", "feature"]]); let pr = pr_event(author, &[&["branch-name", "feature"]]);
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status)); assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
let other = pr_event( let other = pr_event(
+4 -9
View File
@@ -7,17 +7,15 @@ struct GlobalGitStore(GitCache);
impl Global for GlobalGitStore {} impl Global for GlobalGitStore {}
/// Global access to the on-disk git clone cache (grasp mirrors). /// Global access to the on-disk git clone cache, the grasp mirrors.
/// /// Installed at startup via [`GitStore::set_global`].
/// Installed at startup via [`GitStore::set_global`]; see also /// See also [`signed_state::init`].
/// [`signed_state::init`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GitStore(GitCache); pub struct GitStore(GitCache);
impl GitStore { impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global. /// Register the clone cache rooted at `root` as an app-wide global.
/// Replaces any previously installed store (see [`signed_state::init`], which /// Replaces any installed store, [`signed_state::init`] installs an empty one.
/// installs an empty one).
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self { pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root); let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone())); cx.set_global(GlobalGitStore(store.0.clone()));
@@ -25,9 +23,6 @@ impl GitStore {
} }
/// The app-wide clone cache. /// The app-wide clone cache.
///
/// # Panics
///
/// Panics if [`GitStore::set_global`] was never called. /// Panics if [`GitStore::set_global`] was never called.
pub fn global(cx: &App) -> Self { pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone()) Self(cx.global::<GlobalGitStore>().0.clone())
+5 -5
View File
@@ -45,9 +45,10 @@ impl LocalReposStore {
store store
} }
/// Forget a repository that has just been published to NIP-34, /// Forget a repository that has just been published to NIP-34.
/// so it leaves the local list immediately. A later rescan re-discovers it from disk, /// It leaves the local list immediately.
/// the sidebar additionally hides published repositories by identifier. /// A later rescan re-discovers it from disk.
/// The sidebar also hides published repositories by identifier.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) { pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new( self.repos = Arc::new(
self.repos self.repos
@@ -95,8 +96,7 @@ impl LocalReposStore {
dirty dirty
})?; })?;
// Scans requested while this one was running are coalesced into // Scans requested while this one ran are coalesced into one follow-up scan.
// a single follow-up scan.
if again { if again {
this.update(cx, |this, cx| this.rescan(cx))?; this.update(cx, |this, cx| this.rescan(cx))?;
} }
+24 -19
View File
@@ -10,7 +10,7 @@ use utils::shorten_pubkey;
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
/// A user profile (kind `0` metadata), as plain data for the UI. /// A user profile as plain data for the UI, from the kind-0 metadata.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Profile { pub struct Profile {
public_key: PublicKey, public_key: PublicKey,
@@ -62,18 +62,20 @@ impl Profile {
/// Message from the fetch task to the main thread. /// Message from the fetch task to the main thread.
enum Dispatch { enum Dispatch {
/// A batched sync finished; re-read seen profiles from the database. /// A batched sync finished.
/// Re-read seen profiles from the database.
Synced, Synced,
} }
/// How long to wait for more requests before firing a batched sync. /// How long to wait for more requests before firing a batched sync.
const BATCH_TIMEOUT: Duration = Duration::from_millis(500); const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// Global profile cache. Profiles are fetched in batches and kept as plain /// Global profile cache.
/// data; the whole store notifies on change. /// Profiles are fetched in batches and kept as plain data.
/// The whole store notifies on change.
pub struct ProfileStore { pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>, profiles: HashMap<PublicKey, Profile>,
/// Public keys we've already requested this session (main thread only). /// Public keys requested this session, main thread only.
seen: RefCell<HashSet<PublicKey>>, seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task. /// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>, sender: Sender<PublicKey>,
@@ -111,8 +113,8 @@ impl ProfileStore {
_ => {} _ => {}
}); });
// Fetch requests are queued on a channel and synced in batches by a // Fetch requests are queued on a channel.
// background task. // A background task syncs them in batches.
let client = backend.read(cx).client(); let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>(); let (sender, receiver) = flume::unbounded::<PublicKey>();
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>(); let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
@@ -143,8 +145,9 @@ impl ProfileStore {
store store
} }
/// Get a profile. Returns a placeholder (default metadata) and queues a /// Get a profile.
/// fetch if the profile isn't cached yet. /// Returns a placeholder with default metadata.
/// Queues a fetch when the profile is not cached yet.
pub fn get(&self, public_key: &PublicKey) -> Profile { pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(public_key) { if let Some(profile) = self.profiles.get(public_key) {
return profile.clone(); return profile.clone();
@@ -170,7 +173,8 @@ impl ProfileStore {
let filter = Filter::new().kind(Kind::Metadata).limit(200); let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?; let events = client.database().query(filter).await?;
// Parse off the main thread; only plain profiles cross back. // Parse off the main thread.
// Only plain profiles cross back.
let profiles: Vec<Profile> = events let profiles: Vec<Profile> = events
.into_iter() .into_iter()
.map(|event| { .map(|event| {
@@ -205,7 +209,8 @@ impl ProfileStore {
let filter = Filter::new().kind(Kind::Metadata).author(public_key); let filter = Filter::new().kind(Kind::Metadata).author(public_key);
let events = client.database().query(filter).await?; let events = client.database().query(filter).await?;
// Parse off the main thread; only the profile crosses back. // Parse off the main thread.
// Only the profile crosses back.
let profile = events let profile = events
.into_iter() .into_iter()
.max_by_key(|e| e.created_at) .max_by_key(|e| e.created_at)
@@ -231,8 +236,8 @@ impl ProfileStore {
})); }));
} }
/// Re-read the latest metadata of every requested author from the local /// Re-read the latest metadata of every requested author from the local database.
/// database (used after a sync, which produces no NostrUpdate events). /// Used after a sync, which produces no NostrUpdate events.
fn apply_seen(&mut self, cx: &mut Context<Self>) { fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect(); let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
@@ -286,9 +291,9 @@ impl ProfileStore {
})); }));
} }
/// Sync metadata for requested authors in batches, debounced to collect /// Sync metadata for requested authors in batches, debounced to collect requests.
/// requests. Runs on a background thread; results are dispatched to the /// Runs on a background thread.
/// main thread, which re-reads the database. /// Results are dispatched to the main thread, which re-reads the database.
async fn handle_requests( async fn handle_requests(
client: &Client, client: &Client,
dispatch: &Sender<Dispatch>, dispatch: &Sender<Dispatch>,
@@ -316,9 +321,9 @@ impl ProfileStore {
.kind(Kind::Metadata) .kind(Kind::Metadata)
.authors(batch.drain().collect::<Vec<PublicKey>>()); .authors(batch.drain().collect::<Vec<PublicKey>>());
// Negentropy-sync with the bootstrap relays. Synced events are // Negentropy-sync with the bootstrap relays.
// written to the database directly (no NostrUpdate), so re-apply // Synced events are written to the database directly, no NostrUpdate.
// from the database afterwards. // Re-apply from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await { match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => { Ok(_) => {
if dispatch.send(Dispatch::Synced).is_err() { if dispatch.send(Dispatch::Synced).is_err() {
+205 -203
View File
@@ -19,17 +19,17 @@ use crate::backend::{
}; };
use crate::git_store::GitStore; use crate::git_store::GitStore;
/// Delay between a refresh request and the actual re-query, so bursts of /// Delay between a refresh request and the actual re-query.
/// events (e.g. per-event `NostrUpdate`s) collapse into one query. /// Bursts of events, e.g. per-event `NostrUpdate`s, collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum size of one patch event, following NIP-34's guidance that /// Maximum size of one patch event.
/// patches should be used when each event is under 60kb. /// NIP-34 suggests patches when each event is under 60kb.
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Per-repository store: announcement, state, issues, patches, PRs, /// Per-repository store.
/// comments and their resolved statuses. Always derived from the local /// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
/// database. /// Always derived from the local database.
pub struct RepoStore { pub struct RepoStore {
addr: RepoAddr, addr: RepoAddr,
pub announcement: Option<Announcement>, pub announcement: Option<Announcement>,
@@ -42,34 +42,33 @@ pub struct RepoStore {
pub pull_requests: Vec<Event>, pub pull_requests: Vec<Event>,
/// Comments on issues / PRs, oldest first. /// Comments on issues / PRs, oldest first.
pub comments: Vec<Event>, pub comments: Vec<Event>,
/// Resolved status per root event (issue / patch / PR), recomputed on /// Resolved status per root event, issue, patch or PR.
/// every refresh so render paths are HashMap lookups instead of /// Recomputed on every refresh.
/// scanning all status events per root. /// Render paths are HashMap lookups instead of per-root status scans.
/// Those scans are quadratic, with an allocation per pair.
status_by_root: HashMap<EventId, RepoStatus>, status_by_root: HashMap<EventId, RepoStatus>,
/// Open issue / root PR counts, computed with [`Self::status_by_root`] /// Open issue and root PR counts.
/// on every refresh. /// Computed with [`Self::status_by_root`] on every refresh.
open_issue_count: usize, open_issue_count: usize,
open_pr_count: usize, open_pr_count: usize,
/// Kind-1624 cover notes and kind-1985 label events referencing this /// Kind-1624 cover notes and kind-1985 label events.
/// repository's roots (ngit / GitWorkshop extensions). /// They reference this repository's roots, used by ngit and GitWorkshop.
cover_notes: Vec<Event>, cover_notes: Vec<Event>,
labels: Vec<Event>, labels: Vec<Event>,
/// Incremented on every applied refresh; views key their derived-data /// Incremented on every applied refresh.
/// caches to it instead of recomputing on every render. /// Views key their derived-data caches to it instead of recomputing on every render.
version: u64, version: u64,
/// Error of the last action initiated from this store, if any. /// Error of the last action initiated from this store, if any.
pub last_error: Option<String>, pub last_error: Option<String>,
/// Non-fatal warning of the last action (e.g. a PR published without /// Non-fatal warning of the last action, if any.
/// its commit reaching a grasp server), if any. /// Example, a PR published without its commit reaching a grasp server.
pub last_warning: Option<String>, pub last_warning: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we /// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
/// have already been asked to connect to and fetch from, to avoid /// Avoids re-subscribing and re-fetching on every refresh.
/// re-subscribing on every refresh.
repo_relays: HashSet<RelayUrl>, repo_relays: HashSet<RelayUrl>,
/// Root events (issues, patches, PRs) for which the per-root fetches /// Root events, issues, patches and PRs, already fetched per root.
/// (NIP-22 comments, statuses without an `a` tag, cover notes and /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
/// labels) have already been requested, to avoid re-fetching on every /// Also kind-1624 cover notes and kind-1985 labels.
/// refresh.
root_fetches: HashSet<EventId>, root_fetches: HashSet<EventId>,
refreshing: bool, refreshing: bool,
refresh_dirty: bool, refresh_dirty: bool,
@@ -92,12 +91,12 @@ impl RepoStore {
let coordinate = update.coordinate.as_ref() == Some(&this.addr); let coordinate = update.coordinate.as_ref() == Some(&this.addr);
let author = update.author == this.addr.public_key; let author = update.author == this.addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement; let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag, so they can't be // NIP-22 comments carry no `a` tag.
// matched by coordinate; any comment may reference this // Coordinate matching fails for them.
// repository's roots. // Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment; let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag (NIP-34), so any // Status events may omit their `a` tag, NIP-34.
// status event may reference a root of this repository. // Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some(); let status = RepoStatus::from_kind(update.kind).is_some();
// Cover notes and labels carry no `a` tag either. // Cover notes and labels carry no `a` tag either.
let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label; let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label;
@@ -108,9 +107,8 @@ impl RepoStore {
let kind = event.kind == Kind::GitRepoAnnouncement; let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key; let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
// Locally published deletions may target any event of // Locally published deletions may target any event of this repository.
// this repository; refresh so they take effect // Refresh so they take effect immediately, like relay deletions.
// immediately, like relay deletions.
let deletion = let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
@@ -151,9 +149,9 @@ impl RepoStore {
}; };
store.subscribe_remote(cx); store.subscribe_remote(cx);
// The announcement we opened the repo from may already list its // The announcement we opened the repo from may already list its relays.
// relays; connect to them right away instead of waiting for the // Connect to them right away.
// bootstrap fetch to return the same event. // Do not wait for the bootstrap fetch to return the same event.
store.connect_announced_relays(&announced_relays, cx); store.connect_announced_relays(&announced_relays, cx);
store.refresh(cx); store.refresh(cx);
store store
@@ -164,7 +162,7 @@ impl RepoStore {
&self.addr &self.addr
} }
/// Returns the repository's name, or "Unknown" if not known. /// Returns the repository's name, or `Unknown` when not known.
pub fn name(&self) -> SharedString { pub fn name(&self) -> SharedString {
self.announcement self.announcement
.as_ref() .as_ref()
@@ -173,29 +171,26 @@ impl RepoStore {
}) })
} }
/// Filters that make up a repository: announcement, state, /// Filters that make up a repository.
/// activity and deletions targeting it. /// Announcement, state, activity and deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> { fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![ let mut filters = vec![
// Announcement and state share author and identifier, so they // Announcement and state share author and identifier.
// combine into one filter: one fewer negentropy reconciliation // They combine into one filter, one fewer negentropy reconciliation per relay.
// per relay when fetching from the repo's announced relays.
Filter::new() Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key) .author(addr.public_key)
.identifier(addr.identifier.clone()), .identifier(addr.identifier.clone()),
filters::activity(addr), filters::activity(addr),
]; ];
// Deletion requests (NIP-09/62) must be known before any event of // Deletion requests, NIP-09/62, must be known before any event is shown.
// this repository can be shown.
filters.extend(filters::deletions_for_repo(addr)); filters.extend(filters::deletions_for_repo(addr));
filters filters
} }
/// Fetch this repository's events from the relays announced in its /// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
/// NIP-34 `relays` tag. Deduplicated: each relay is only contacted once /// Deduplicated, each relay is contacted once per store.
/// per store, so refreshes after the first are no-ops unless the /// Refreshes after the first are no-ops unless the announcement lists new relays.
/// announcement lists new relays.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) { fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let new: Vec<RelayUrl> = relays let new: Vec<RelayUrl> = relays
.iter() .iter()
@@ -214,7 +209,7 @@ impl RepoStore {
}); });
} }
/// Fetch this repository's events from the bootstrap relays /// Fetch this repository's events from the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) { fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let addr = self.addr.clone(); let addr = self.addr.clone();
@@ -225,9 +220,8 @@ impl RepoStore {
} }
/// Re-query the local database and update all fields. /// Re-query the local database and update all fields.
/// /// The query and processing run on a background thread.
/// The query and processing run on a background thread, /// Only the results are applied on the main thread.
/// only the results are applied on the main thread.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing { if self.refreshing {
self.refresh_dirty = true; self.refresh_dirty = true;
@@ -272,8 +266,8 @@ impl RepoStore {
let deletions = Deletions::from_events(deletion_events); let deletions = Deletions::from_events(deletion_events);
// Parse and sort off the main thread; only plain data // Parse and sort off the main thread.
// crosses back into the entity. // Only plain data crosses back into the entity.
let all_announcements = announcements let all_announcements = announcements
.into_iter() .into_iter()
.filter(|e| !deletions.is_deleted(e)); .filter(|e| !deletions.is_deleted(e));
@@ -302,9 +296,8 @@ impl RepoStore {
} }
} }
// NIP-22 comments reference their root via an `E`/`e` tag rather // NIP-22 comments reference their root via an `E` or `e` tag.
// than the repository's `a` tag, so query them by the root events // Not the repository's `a` tag, so query them by the root events.
// of this repository.
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect(); let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
let db = client.database(); let db = client.database();
@@ -322,8 +315,8 @@ impl RepoStore {
} }
} }
// Status events may omit their `a` tag, // Status events may omit their `a` tag.
// so also query them by the root events they reference. // Query them by the root events they reference too.
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect(); let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
let db = client.database(); let db = client.database();
@@ -341,8 +334,8 @@ impl RepoStore {
} }
} }
// Cover notes (1624) and label events (1985) reference // Cover notes, 1624, and label events, 1985, carry no `a` tag.
// so query them per root like comments and statuses. // Query them per root like comments and statuses.
let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect(); let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect();
let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect(); let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect();
let db = client.database(); let db = client.database();
@@ -373,9 +366,9 @@ impl RepoStore {
sort_newest_first(&mut cover_notes); sort_newest_first(&mut cover_notes);
sort_newest_first(&mut labels); sort_newest_first(&mut labels);
// Resolve every root's status once here; render paths do // Resolve every root's status once here.
// HashMap lookups instead of scanning all status events per // Render paths do HashMap lookups instead of per-root status scans.
// root (quadratic, with an allocation per pair). // Those scans are quadratic, with an allocation per pair.
let maintainers = announcement let maintainers = announcement
.as_ref() .as_ref()
.map(Announcement::effective_maintainers) .map(Announcement::effective_maintainers)
@@ -441,8 +434,8 @@ impl RepoStore {
let again = this.update(cx, |this, cx| { let again = this.update(cx, |this, cx| {
this.announcement = announcement; this.announcement = announcement;
// The announcement may list relays for this repository's activity, // The announcement may list relays for this repository's activity.
// connect to any we haven't fetched from yet. // Connect to any we have not fetched from yet.
let relays = this let relays = this
.announcement .announcement
.as_ref() .as_ref()
@@ -466,10 +459,10 @@ impl RepoStore {
this.labels = labels; this.labels = labels;
this.version = this.version.wrapping_add(1); this.version = this.version.wrapping_add(1);
// Comments, statuses without an `a` tag, cover notes and // Comments, statuses without an `a` tag, cover notes and labels.
// labels are not addressed to the repository, so fetch them // None are addressed to the repository.
// by the root events they reference, on the bootstrap relays // Fetch them by the root events they reference.
// and on the relays this repository announced. // Use the bootstrap relays and the relays this repository announced.
let roots = this let roots = this
.issues .issues
.iter() .iter()
@@ -486,10 +479,9 @@ impl RepoStore {
if !new_roots.is_empty() { if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied()); this.root_fetches.extend(new_roots.iter().copied());
// Batch the per-root filters: one statuses filter and one // Batch the per-root filters.
// annotations filter covering all new roots, instead of // One statuses filter and one annotations filter cover all new roots.
// one filter per root (each filter is a separate // One filter per root costs a negentropy reconciliation per relay.
// negentropy reconciliation per relay).
let mut root_filters = filters::comments_for(new_roots.clone()); let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied())); root_filters.push(filters::statuses_for(new_roots.iter().copied()));
root_filters.push(filters::annotations_for(new_roots)); root_filters.push(filters::annotations_for(new_roots));
@@ -513,7 +505,8 @@ impl RepoStore {
} }
})?; })?;
// Requests that arrived while the refresh was running are coalesced into one follow-up refresh. // Requests that arrived while the refresh was running.
// They are coalesced into one follow-up refresh.
if again { if again {
this.update(cx, |this, cx| this.refresh(cx))?; this.update(cx, |this, cx| this.refresh(cx))?;
} }
@@ -522,7 +515,7 @@ impl RepoStore {
})); }));
} }
/// Resolve the status of a root event (issue / patch / PR) per NIP-34 /// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
pub fn status_of(&self, root: &Event) -> RepoStatus { pub fn status_of(&self, root: &Event) -> RepoStatus {
status_of(&self.status_by_root, root) status_of(&self.status_by_root, root)
} }
@@ -533,8 +526,8 @@ impl RepoStore {
self.version self.version
} }
/// The effective cover note of `root` (kind 1624), if any: /// The effective cover note of `root`, kind 1624, if any.
/// the latest note authored by the root author or a maintainer. /// The latest note authored by the root author or a maintainer.
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -545,8 +538,8 @@ impl RepoStore {
cover_note(root, &self.cover_notes, &maintainers) cover_note(root, &self.cover_notes, &maintainers)
} }
/// The effective hashtag labels of `root`: its own `t` tags plus labels /// The effective hashtag labels of `root`.
/// from authorized NIP-32 kind-1985 events (`#t` namespace). /// Its own `t` tags plus labels from NIP-32 kind-1985 events in the `#t` namespace.
pub fn labels_of(&self, root: &Event) -> Vec<String> { pub fn labels_of(&self, root: &Event) -> Vec<String> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -558,8 +551,8 @@ impl RepoStore {
labels labels
} }
/// The effective subject/title override of `root` from authorized /// The effective subject or title override of `root`, if any.
/// kind-1985 events (`#subject` namespace), if any. /// Comes from authorized kind-1985 events in the `#subject` namespace.
pub fn subject_of(&self, root: &Event) -> Option<String> { pub fn subject_of(&self, root: &Event) -> Option<String> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -570,22 +563,23 @@ impl RepoStore {
subject_override(root, &self.labels, &maintainers) subject_override(root, &self.labels, &maintainers)
} }
/// Number of open issues: issues whose resolved status is /// Number of open issues.
/// [`RepoStatus::Open`] (issues without status events default to open). /// Issues whose resolved status is [`RepoStatus::Open`].
/// Issues without status events default to open.
pub fn issue_count(&self) -> usize { pub fn issue_count(&self) -> usize {
self.open_issue_count self.open_issue_count
} }
/// Number of open pull requests: root PR events (not PR updates, whose /// Number of open pull requests.
/// status is carried by the root) with a resolved status of /// Only root PR events count, PR updates do not.
/// [`RepoStatus::Open`]. /// They must resolve to [`RepoStatus::Open`].
pub fn pull_request_count(&self) -> usize { pub fn pull_request_count(&self) -> usize {
self.open_pr_count self.open_pr_count
} }
/// Whether `user` is the author (owner) of this repository: the public /// Whether `user` is the author or owner of this repository.
/// key of the repository address. Only the author may manage the /// The author is the public key of the repository address.
/// repository's pull requests (close / reopen / merge). /// Only the author may manage pull requests, close, reopen or merge.
pub fn is_author(&self, user: &PublicKey) -> bool { pub fn is_author(&self, user: &PublicKey) -> bool {
&self.addr.public_key == user &self.addr.public_key == user
} }
@@ -603,19 +597,19 @@ impl RepoStore {
self.send(builder, cx); self.send(builder, cx);
} }
/// Comments on a root event (issue / PR), oldest first. /// Comments on a root event, an issue or PR, oldest first.
pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> { pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> {
self.comments self.comments
.iter() .iter()
.filter(move |e| signed_core::references_root(e, root)) .filter(move |e| signed_core::references_root(e, root))
} }
/// Comment on a root event (issue / PR) per NIP-34 (kind 1111) /// Comment on a root event, an issue or PR, per NIP-34, kind 1111.
pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) { pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) {
self.reply(root, None, content, cx); self.reply(root, None, content, cx);
} }
/// Reply to `parent` (a comment on `root`) with a NIP-22 threaded comment, /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment.
/// `None` publishes a top-level comment on the root itself. /// `None` publishes a top-level comment on the root itself.
pub fn reply( pub fn reply(
&mut self, &mut self,
@@ -636,31 +630,32 @@ impl RepoStore {
); );
} }
/// Open a pull request on this repository: a root PR event (kind 1618) /// Open a pull request on this repository.
/// whose content is the markdown description, plus a root patch event /// A root PR event, kind 1618, carries the markdown description.
/// (kind 1617) carrying the `git format-patch` output, which the PR /// A root patch event, kind 1617, carries the `git format-patch` output.
/// references via an `e` tag (NIP-34). /// The PR references the patch via an `e` tag, NIP-34.
/// /// The patch series is published first.
/// The patch series is published first (one kind-1617 event per commit, /// One kind-1617 event per commit, chained with NIP-10 `e` replies.
/// chained with NIP-10 `e` replies, each under [`MAX_PATCH_EVENT_BYTES`]) /// Each event stays under [`MAX_PATCH_EVENT_BYTES`].
/// so the PR can reference the root patch's id. The proposed commit is /// The PR then references the root patch's id.
/// parsed from the series' last `From <commit>` header (the tip); without /// The proposed commit is the series tip.
/// one publishing is refused, because the PR's `c` tag must carry a real /// It comes from the last `From <commit>` header.
/// commit id for other NIP-34 clients to verify and apply the proposal. /// Publishing is refused without one.
/// /// The PR's `c` tag must carry a real commit id.
/// The `clone` tag carries the author's GRASP-06 `/prs/` URLs first /// Other NIP-34 clients verify and apply the proposal from it.
/// (resolved from their kind-10317 grasp list, falling back to the /// The `clone` tag lists the author's GRASP-06 `/prs/` URLs first.
/// settings defaults) plus the announced mirror URLs, so the tip is /// Taken from the author's kind-10317 grasp list, else the settings defaults.
/// downloadable on the author's own hosting even when the base project /// The announced mirror URLs follow.
/// accepts nothing. When `push_from` is set, the tip is pushed to those /// The tip stays downloadable on the author's hosting.
/// servers under `refs/nostr/<event-id>` (best-effort, author servers /// This holds even when the base project accepts nothing.
/// first) before the PR is published; the linked patch stays the source /// With `push_from` set, the tip is pushed to those servers.
/// of truth either way. /// The ref is `refs/nostr/<event-id>`, author servers first, best-effort.
/// /// The push happens before the PR is published.
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft` /// The linked patch stays the source of truth either way.
/// publishes a kind-1633 status right after the PR event. `merge_base` /// `branch_name` lands in the PR's `branch-name` tag, NIP-34.
/// is the hex commit the proposed branch forked from, computed from a /// `draft` publishes a kind-1633 status right after the PR event.
/// local checkout when the patch was generated there. /// `merge_base` is the hex commit the proposed branch forked from.
/// It is computed from a local checkout when the patch was generated there.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn open_pull_request( pub fn open_pull_request(
&mut self, &mut self,
@@ -694,7 +689,8 @@ impl RepoStore {
return; return;
} }
// The tip of the series is its last commit; `git format-patch` orders patches oldest first. // The tip of the series is its last commit.
// `git format-patch` orders patches oldest first.
let Some(current_commit) = series let Some(current_commit) = series
.last() .last()
.and_then(|part| patch_current_commit(part)) .and_then(|part| patch_current_commit(part))
@@ -715,7 +711,7 @@ impl RepoStore {
cx.notify(); cx.notify();
return; return;
}; };
// The author's npub names their GRASP-06 namespace (`/prs/...`). // The author's npub names their GRASP-06 namespace, `/prs/...`.
let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex()); let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex());
let addr = self.addr.clone(); let addr = self.addr.clone();
@@ -729,8 +725,8 @@ impl RepoStore {
.map(|a| a.relays.clone()) .map(|a| a.relays.clone())
.unwrap_or_default(); .unwrap_or_default();
// GRASP-06 hosting falls back to the settings defaults when // GRASP-06 hosting falls back to the settings defaults.
// the author has no published grasp list // That happens when the author has no published grasp list.
let defaults: Vec<RelayUrl> = { let defaults: Vec<RelayUrl> = {
let settings = settings::SettingsStore::global(cx).read(cx).settings(); let settings = settings::SettingsStore::global(cx).read(cx).settings();
let urls: Vec<String> = if settings.grasp_servers.default_servers.is_empty() { let urls: Vec<String> = if settings.grasp_servers.default_servers.is_empty() {
@@ -747,7 +743,8 @@ impl RepoStore {
}; };
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
// The PR references the root patch event so viewers can find the patch without carrying it inline. // The PR references the root patch event.
// Viewers can then find the patch without carrying it inline.
let root_patch = match publish_patch_series( let root_patch = match publish_patch_series(
&this, &this,
cx, cx,
@@ -769,12 +766,11 @@ impl RepoStore {
} }
}; };
// GRASP-06: the tip is pushed to the author's own grasp servers // GRASP-06 pushes the tip to the author's own grasp servers.
// under `/prs/<author-npub>/<repo-id>.git`, so contributing to // The path is `/prs/<author-npub>/<repo-id>.git`.
// someone else's project never depends on their servers // Contributing to another project never depends on that project's servers.
// accepting the push. Resolve them from the author's latest // Resolve the servers from the author's latest kind-10317 grasp list.
// kind-10317 grasp list; the settings defaults stand in when no // The settings defaults stand in when no list is published or the query fails.
// list is published (or the query fails).
let author_servers = { let author_servers = {
let query = this.update(cx, |_this, cx| { let query = this.update(cx, |_this, cx| {
let client = Backend::global(cx).read(cx).client(); let client = Backend::global(cx).read(cx).client();
@@ -814,13 +810,15 @@ impl RepoStore {
}; };
let builder = this.update(cx, |this, _cx| { let builder = this.update(cx, |this, _cx| {
// NIP-34: PRs carry at least one clone URL where the tip // NIP-34 PRs carry at least one clone URL.
// commit can be downloaded. The author's `/prs/` URLs come // The tip commit is downloadable from it.
// first (author-controlled, most likely alive), then the // The author's `/prs/` URLs come first.
// announced mirrors. The list is fixed before signing: the // They are author-controlled and most likely alive.
// pushed ref name embeds the event id, so every candidate // The announced mirrors follow.
// URL is listed up front; dead URLs are inert, the linked // The list is fixed before signing.
// patch stays the source of truth. // The pushed ref name embeds the event id.
// Every candidate URL is listed up front.
// Dead URLs are inert, the linked patch stays the source of truth.
let prs_urls: Vec<Url> = author_targets let prs_urls: Vec<Url> = author_targets
.iter() .iter()
.filter_map(|(url, _)| Url::parse(url).ok()) .filter_map(|(url, _)| Url::parse(url).ok())
@@ -846,16 +844,16 @@ impl RepoStore {
} }
.into_event_builder(); .into_event_builder();
// NIP-34: the `r` EUC tag lets clients subscribe to all PRs of this repository // The `r` EUC tag lets clients subscribe to all PRs of the repository.
match this.announcement.as_ref().and_then(|a| a.euc.clone()) { match this.announcement.as_ref().and_then(|a| a.euc.clone()) {
Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")), Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")),
None => builder, None => builder,
} }
})?; })?;
// Sign before publishing so the tip can be pushed to the grasp // Sign before publishing.
// servers under `refs/nostr/<event-id>` (nak's convention): // The tip is pushed to the grasp servers under `refs/nostr/<event-id>`.
// readers fetch that ref to get the commit behind the `c` tag. // Nak's convention, readers fetch that ref for the commit behind the `c` tag.
let event = cx let event = cx
.background_spawn({ .background_spawn({
let signer = signer.clone(); let signer = signer.clone();
@@ -871,8 +869,8 @@ impl RepoStore {
let path = path.clone(); let path = path.clone();
let tip = tip.clone(); let tip = tip.clone();
let reference = reference.clone(); let reference = reference.clone();
// Author servers first, then the base repository's // Author servers first, then the announced base grasp servers.
// announced grasp servers (best-effort redundancy). // The extra targets are best-effort redundancy.
let targets: Vec<(String, String)> = author_targets let targets: Vec<(String, String)> = author_targets
.into_iter() .into_iter()
.chain(base_targets) .chain(base_targets)
@@ -919,8 +917,8 @@ impl RepoStore {
} }
}; };
// NIP-34: a draft PR carries a kind-1633 status event, // A draft PR carries a kind-1633 status event, NIP-34.
// publish it right after the PR event so viewers never show it open. // Publish it right after the PR event so viewers never show it open.
if draft { if draft {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_status(&pr_event, RepoStatus::Draft, cx); this.set_status(&pr_event, RepoStatus::Draft, cx);
@@ -931,11 +929,12 @@ impl RepoStore {
})); }));
} }
/// Update a pull request: publish revision patch events chained to the /// Update a pull request.
/// original root patch (`t root-revision` and a NIP-10 `e` reply on the /// Publish revision patch events chained to the original root patch.
/// first, per NIP-34), then a kind-1619 PR update event carrying the new tip. /// The first event carries `t root-revision` and a NIP-10 `e` reply, per NIP-34.
/// /// Then a kind-1619 PR update event carries the new tip.
/// Only the PR author may update it; other authors must open a new PR. /// Only the PR author may update it.
/// Other authors must open a new PR.
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) { pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
self.last_warning = None; self.last_warning = None;
@@ -984,9 +983,8 @@ impl RepoStore {
return; return;
}; };
// NIP-34: the first patch of a revision replies to the original // The first revision patch replies to the original root patch, NIP-34.
// root patch (the PR's `e` tag; fall back to the oldest patch of // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none.
// the linked set for PRs without one).
let root_patch_id = root.tags.event_ids().next().or_else(|| { let root_patch_id = root.tags.event_ids().next().or_else(|| {
pull_request_patches(root, self.patches.iter()) pull_request_patches(root, self.patches.iter())
.first() .first()
@@ -1033,8 +1031,8 @@ impl RepoStore {
} }
.into_event_builder(); .into_event_builder();
// NIP-34: the `r` EUC tag lets clients subscribe to all PR // The `r` EUC tag lets clients subscribe to all PR updates.
// updates of this repository; the SDK builder omits it. // The SDK builder omits it.
let builder = match euc.as_deref() { let builder = match euc.as_deref() {
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
None => builder, None => builder,
@@ -1055,9 +1053,9 @@ impl RepoStore {
})); }));
} }
/// Set the status of a root event. Per NIP-34 only the root author or a /// Set the status of a root event.
/// repository maintainer may set the status; status events from anyone /// Only the root author or a maintainer may set it, per NIP-34.
/// else are ignored by clients, so refuse them up front. /// Status events from anyone else are ignored by clients, so refuse them up front.
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) { pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
@@ -1094,9 +1092,10 @@ impl RepoStore {
self.send(builder, cx); self.send(builder, cx);
} }
/// Publish a repository state announcement (kind 30618) with the refs of /// Publish a repository state announcement, kind 30618.
/// the local clone: branches, tags and HEAD. Only the repository owner /// It carries the local clone's branches, tags and HEAD.
/// may publish state, and a local clone must exist to read the refs from. /// Only the repository owner may publish state.
/// A local clone must exist to read the refs from.
pub fn publish_state(&mut self, cx: &mut Context<Self>) { pub fn publish_state(&mut self, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
@@ -1146,17 +1145,17 @@ impl RepoStore {
})); }));
} }
/// Merge a pull request: apply its patch (the content of the linked /// Merge a pull request.
/// root patch event) to the local clone of this repository, then publish /// Apply its patch, the linked root patch event's content, to the local clone.
/// a kind-1631 (Applied) status event with merge provenance: the commits /// Then publish a kind-1631 Applied status event with merge provenance.
/// `git am` created (`applied-as-commits` + `r` tags) and the applied /// The provenance covers the commits `git am` created.
/// patch events (`q` tags, plus `e` reply tags for every patch beyond /// They appear as `applied-as-commits` and `r` tags.
/// the root, per NIP-34). /// It also tags the applied patch events.
/// /// `q` tags per event and `e` replies for every patch beyond the root, NIP-34.
/// Only the repository author may merge. The clone is created on demand /// Only the repository author may merge.
/// from the announcement's clone URLs when needed. Patch application /// The clone is created on demand from the announcement's clone URLs.
/// (`git am`) runs on a background thread; failures (e.g. a patch that /// Patch application, `git am`, runs on a background thread.
/// no longer applies) surface in [`Self::last_error`]. /// Failures, e.g. a patch that no longer applies, surface in [`Self::last_error`].
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) { pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
self.last_warning = None; self.last_warning = None;
@@ -1198,8 +1197,8 @@ impl RepoStore {
.workdir() .workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf(); .to_path_buf();
// The commits created by the apply: everything between the // The commits the apply created.
// previous HEAD and the new one, oldest first. // Everything between the previous HEAD and the new one, oldest first.
let previous = signed_git::head_commit_id(&workdir)?; let previous = signed_git::head_commit_id(&workdir)?;
signed_git::apply_patch(&workdir, &patch)?; signed_git::apply_patch(&workdir, &patch)?;
let applied = signed_git::commits_since(&workdir, previous.as_deref())?; let applied = signed_git::commits_since(&workdir, previous.as_deref())?;
@@ -1231,10 +1230,10 @@ impl RepoStore {
})); }));
} }
/// Publish a kind-1631 (Applied) status event for `root` after a merge: /// Publish a kind-1631 Applied status event for `root` after a merge.
/// `applied-as-commits` + `r` tags for the commits `git am` created, /// `applied-as-commits` and `r` tags name the commits `git am` created.
/// `q` tags for the applied patch events, and `e` reply tags for every /// `q` tags name the applied patch events.
/// patch of the series beyond the root (NIP-34). /// `e` reply tags cover every patch of the series beyond the root, NIP-34.
fn publish_applied_status( fn publish_applied_status(
&mut self, &mut self,
root: &Event, root: &Event,
@@ -1255,9 +1254,9 @@ impl RepoStore {
{ {
tags.push(tag); tags.push(tag);
} }
// The applied patch events: a `q` tag per event, plus an `e` reply // Tag each applied patch event.
// for every event beyond the root (chain parts and revisions), so // `q` per event, `e` reply for events beyond the root, chain parts and revisions.
// their statuses resolve to Applied too. // Their statuses then resolve to Applied too.
for (ix, patch) in patches.iter().enumerate() { for (ix, patch) in patches.iter().enumerate() {
if let Ok(tag) = if let Ok(tag) =
Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()]) Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()])
@@ -1312,8 +1311,9 @@ where
events.into_iter().max_by_key(|e| e.created_at) events.into_iter().max_by_key(|e| e.created_at)
} }
/// Status of `root` from the precomputed map; roots without status events /// Status of `root` from the precomputed map.
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`]. /// Roots without status events default to [`RepoStatus::Open`].
/// Matches [`signed_core::resolve_status`].
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus { fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root status_by_root
.get(&root.id) .get(&root.id)
@@ -1321,10 +1321,10 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> Rep
.unwrap_or(RepoStatus::Open) .unwrap_or(RepoStatus::Open)
} }
/// Resolve the status of every root event in one pass: status events are /// Resolve every root event's status in one pass.
/// indexed by the root they reference (`e`/`E` tag), then each root /// Status events are indexed by the root they reference, the `e` or `E` tag.
/// resolves against its own slice. O(roots + statuses) instead of the /// Each root resolves against its own slice.
/// O(roots × statuses) of resolving per root on demand. /// Linear in roots and statuses, per-root resolution is their product.
fn resolve_statuses( fn resolve_statuses(
issues: &[Event], issues: &[Event],
patches: &[Event], patches: &[Event],
@@ -1364,20 +1364,21 @@ fn sort_oldest_first(events: &mut [Event]) {
events.sort_by_key(|e| e.created_at); events.sort_by_key(|e| e.created_at);
} }
/// The proposed commit of a `git format-patch` output: the `From <commit>` /// The proposed commit of a `git format-patch` output.
/// header on its first line. /// It is the `From <commit>` header on the first line.
fn patch_current_commit(patch: &str) -> Option<&str> { fn patch_current_commit(patch: &str) -> Option<&str> {
let line = patch.lines().next()?; let line = patch.lines().next()?;
let hex = line.strip_prefix("From ")?; let hex = line.strip_prefix("From ")?;
hex.split_whitespace().next().filter(|hex| hex.len() == 40) hex.split_whitespace().next().filter(|hex| hex.len() == 40)
} }
/// Publish a `git format-patch` series as chained kind-1617 events and /// Publish a `git format-patch` series as chained kind-1617 events.
/// return the root event (the one a PR references). The first part carries /// Returns the root event, the one a PR references.
/// `first_marker` (`t root`, or `t root-revision` with an `e` reply to /// The first part carries `first_marker`.
/// `reply_to` for revisions); every later part replies to the previous one /// That is `t root`, or `t root-revision` with an `e` reply to `reply_to` for revisions.
/// (NIP-34). Every part gets the repository coordinate, the owner, its own /// Every later part replies to the previous one, NIP-34.
/// `commit`/`r` tags, and the repository EUC when known. /// Every part gets the repository coordinate, the owner and its `commit` and `r` tags.
/// The repository EUC is added when known.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn publish_patch_series( async fn publish_patch_series(
this: &WeakEntity<RepoStore>, this: &WeakEntity<RepoStore>,
@@ -1444,10 +1445,11 @@ async fn publish_patch_series(
root.ok_or_else(|| anyhow::anyhow!("patch series is empty")) root.ok_or_else(|| anyhow::anyhow!("patch series is empty"))
} }
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the /// Build a NIP-22 kind-1111 comment.
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a /// Uppercase `E`, `K` and `P` tags scope the thread root.
/// top-level comment). An `a` tag with the repository coordinate (not part /// Lowercase `e`, `k` and `p` tag the direct parent, or the root for a top-level comment.
/// of NIP-22) is added so Signed's own activity subscriptions also match. /// An `a` tag with the repository coordinate is added, not part of NIP-22.
/// Signed's own activity subscriptions then match it too.
fn comment_builder( fn comment_builder(
root: &Event, root: &Event,
parent: Option<&Event>, parent: Option<&Event>,
@@ -1514,15 +1516,15 @@ mod tests {
assert!(kinds.contains(&expected), "missing {expected} tag"); assert!(kinds.contains(&expected), "missing {expected} tag");
} }
// The uppercase `E` tag scopes the root: id, relay hint and author. // The uppercase `E` tag scopes the root, with its id, relay hint and author.
let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
let slice = e.as_slice(); let slice = e.as_slice();
assert_eq!(slice[1], root.id.to_hex()); assert_eq!(slice[1], root.id.to_hex());
assert_eq!(slice[2], relay.as_str()); assert_eq!(slice[2], relay.as_str());
assert_eq!(slice[3], root.pubkey.to_hex()); assert_eq!(slice[3], root.pubkey.to_hex());
// The lowercase `e` tag references the parent, which for a top-level // The lowercase `e` tag references the parent.
// comment is the root itself. // For a top-level comment the parent is the root itself.
let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
assert_eq!(e.as_slice()[1], root.id.to_hex()); assert_eq!(e.as_slice()[1], root.id.to_hex());
@@ -1545,8 +1547,8 @@ mod tests {
.finalize(&keys) .finalize(&keys)
.expect("signed event"); .expect("signed event");
// The uppercase `E` tag still scopes the root event, while the // The uppercase `E` tag still scopes the root event.
// lowercase `e` tag references the parent comment. // The lowercase `e` tag references the parent comment.
let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
assert_eq!(root_ref.as_slice()[1], root.id.to_hex()); assert_eq!(root_ref.as_slice()[1], root.id.to_hex());
+53 -55
View File
@@ -9,8 +9,8 @@ use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
use crate::backend::{Backend, BackendEvent}; use crate::backend::{Backend, BackendEvent};
/// Delay between a refresh request and the actual re-query, so bursts of /// Delay between a refresh request and the actual re-query.
/// events (e.g. sync progress ticks) collapse into one query. /// Bursts of events, e.g. sync progress ticks, collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How far back activity events count toward a repository's last activity. /// How far back activity events count toward a repository's last activity.
@@ -20,40 +20,39 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
impl Global for GlobalRepoListStore {} impl Global for GlobalRepoListStore {}
/// Counts of NIP-34 activity events per repository, used to rank the /// NIP-34 activity event counts per repository, ranking the explore list by popularity.
/// explore list by popularity. Each patch event is a pushed commit (or a /// Each patch event is a pushed commit or a small series.
/// small series), the closest proxy for commit count in the event data. /// That is the closest proxy for commit count in the event data.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts { pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository. /// Root `30611` issue events addressed to the repository.
pub issues: u32, pub issues: u32,
/// Root `3063` pull request events addressed to the repository /// Root `3063` pull request events addressed to the repository.
/// (updates to a PR are not new PRs and don't count). /// PR updates are not new PRs and do not count.
pub pull_requests: u32, pub pull_requests: u32,
/// `1617` patch events addressed to the repository. /// `1617` patch events addressed to the repository.
pub commits: u32, pub commits: u32,
} }
impl RepoActivityCounts { impl RepoActivityCounts {
/// Total issues + pull requests + commits; the popularity ranking key. /// Total issues, pull requests and commits, the popularity ranking key.
pub fn score(self) -> u32 { pub fn score(self) -> u32 {
self.issues + self.pull_requests + self.commits self.issues + self.pull_requests + self.commits
} }
} }
/// Store listing repository announcements (global discovery or per-author). /// Store listing repository announcements, global discovery or per-author.
/// /// The all-repos store, `author: None`, is created at startup by [`crate::init`].
/// The all-repos store (`author: None`) is created at startup by /// Installed as a global.
/// [`crate::init`] and installed as a global, so the explore panel renders /// The explore panel renders from the local database without waiting for relays.
/// what's in the local database without waiting for relays.
pub struct RepoListStore { pub struct RepoListStore {
/// Shared so views can clone the list per frame without a deep copy. /// Shared so views can clone the list per frame without a deep copy.
pub announcements: Arc<Vec<Announcement>>, pub announcements: Arc<Vec<Announcement>>,
/// Latest known activity timestamp per repository /// Latest known activity timestamp per repository.
/// (announcements, state updates, patches, PRs, issues, statuses). /// Covers announcements, state updates, patches, PRs, issues and statuses.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>, pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues + pull requests + commits per repository, for the Popular /// Issues, pull requests and commits per repository.
/// ranking of the explore list. /// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>, pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
author: Option<PublicKey>, author: Option<PublicKey>,
refreshing: bool, refreshing: bool,
@@ -65,8 +64,8 @@ pub struct RepoListStore {
} }
impl RepoListStore { impl RepoListStore {
/// Retrieve the global explore store (all announcements, created at /// Retrieve the global explore store.
/// startup by [`crate::init`]). /// It lists all announcements and is created at startup by [`crate::init`].
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRepoListStore>().0.clone() cx.global::<GlobalRepoListStore>().0.clone()
} }
@@ -82,12 +81,12 @@ impl RepoListStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event { let relevant = match event {
BackendEvent::NostrUpdate(update) => { BackendEvent::NostrUpdate(update) => {
// Deletions may target anything we list; always refresh. // Deletions may target anything we list, always refresh.
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish { if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true true
} else if filters::ACTIVITY_KINDS.contains(&update.kind) { } else if filters::ACTIVITY_KINDS.contains(&update.kind) {
// Activity (patches, issues, ...) is addressed to repos via // Activity events are addressed to repos via `a` tags.
// `a` tags, so its author isn't the repo owner; always refresh. // Their author is not the repo owner, always refresh.
true true
} else { } else {
let is_announcement = update.kind == Kind::GitRepoAnnouncement; let is_announcement = update.kind == Kind::GitRepoAnnouncement;
@@ -99,9 +98,8 @@ impl RepoListStore {
BackendEvent::Published(event) => { BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement let announcement = event.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == event.pubkey); && this.author.is_none_or(|a| a == event.pubkey);
// Locally published deletions (e.g. deleting a repo) // Locally published deletions are already in the local database.
// are already in the local database; refresh so they // Refresh so they take effect immediately, like relay deletions.
// take effect immediately, like relay deletions.
let deletion = let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || deletion announcement || deletion
@@ -128,13 +126,13 @@ impl RepoListStore {
}; };
store.subscribe_remote(cx); store.subscribe_remote(cx);
// Query the local database right away; the list never waits for the // Query the local database right away.
// relay syncs started above to finish. // The list never waits for the relay syncs started above to finish.
store.refresh_initial(cx); store.refresh_initial(cx);
store store
} }
/// Scope the list to an author (or clear the scope with `None`). /// Scope the list to an author, or clear the scope with `None`.
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) { pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
self.author = author; self.author = author;
self.subscribe_remote(cx); self.subscribe_remote(cx);
@@ -152,14 +150,14 @@ impl RepoListStore {
None => filters::all_announcements(), None => filters::all_announcements(),
}; };
backend.sync_bootstrap(filter, cx); backend.sync_bootstrap(filter, cx);
// Deletion requests (NIP-09/62) must be known before any // Deletion requests, NIP-09/62, must be known before any announcement is shown.
// announcement can be shown.
backend.sync_bootstrap(filters::deletions(), cx); backend.sync_bootstrap(filters::deletions(), cx);
}); });
} }
/// One-shot initial load: query the local database immediately (no /// One-shot initial load.
/// debounce), so stored announcements appear as soon as the app opens. /// Query the local database immediately, no debounce.
/// Stored announcements appear as soon as the app opens.
/// Only called from [`Self::new`], before any refresh can be pending. /// Only called from [`Self::new`], before any refresh can be pending.
fn refresh_initial(&mut self, cx: &mut Context<Self>) { fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.debouncing); debug_assert!(!self.debouncing);
@@ -170,12 +168,12 @@ impl RepoListStore {
self.run_refresh(cx); self.run_refresh(cx);
} }
/// Re-query the local database. Latest announcement per repository wins. /// Re-query the local database.
/// /// The latest announcement per repository wins.
/// Debounced: a short delay collapses bursts of requests (e.g. sync /// A short debounce collapses bursts of requests, e.g. sync progress ticks.
/// progress ticks), and requests that arrive while a query is running /// Requests that arrive while a query runs fold into one follow-up query.
/// are folded into one follow-up query. The query and processing run on /// The query and processing run on a background thread.
/// a background thread; only the results are applied on the main thread. /// Only the results are applied on the main thread.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing { if self.refreshing {
self.refresh_dirty = true; self.refresh_dirty = true;
@@ -198,7 +196,7 @@ impl RepoListStore {
self.tasks.push(task); self.tasks.push(task);
} }
/// One query + apply cycle (debounced entry point). /// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) { fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true; self.refreshing = true;
@@ -216,8 +214,8 @@ impl RepoListStore {
let deletion_events = client.database().query(filters::deletions()).await?; let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events); let deletions = Deletions::from_events(deletion_events);
// Dedup and sort off the main thread; only the final list // Dedup and sort off the main thread.
// crosses back into the entity. // Only the final list crosses back into the entity.
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new(); let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
for event in events { for event in events {
@@ -242,8 +240,9 @@ impl RepoListStore {
let mut announcements: Vec<Announcement> = by_repo.into_values().collect(); let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
// Last activity per repository: state updates plus all NIP-34 // Last activity per repository.
// activity events (patches, PRs, issues, statuses). // 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 let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
.iter() .iter()
.map(|a| (a.addr(), a.created_at)) .map(|a| (a.addr(), a.created_at))
@@ -264,8 +263,8 @@ impl RepoListStore {
*entry = (*entry).max(event.created_at); *entry = (*entry).max(event.created_at);
} }
// Bound the activity query to a recent window; older repos fall // Bound the activity query to a recent window.
// back to their announcement / state timestamps. // Older repos fall back to their announcement or state timestamps.
let activity_filter = Filter::new() let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS) .kinds(filters::ACTIVITY_KINDS)
.since(Timestamp::now() - ACTIVITY_WINDOW); .since(Timestamp::now() - ACTIVITY_WINDOW);
@@ -277,8 +276,8 @@ impl RepoListStore {
if addr.kind != Kind::GitRepoAnnouncement { if addr.kind != Kind::GitRepoAnnouncement {
continue; continue;
} }
// Skip events for repos we don't list, so the map can't // Skip events for repos we do not list.
// grow beyond the number of announcements. // The map cannot grow beyond the number of announcements.
let Some(entry) = last_activity.get_mut(&addr) else { let Some(entry) = last_activity.get_mut(&addr) else {
continue; continue;
}; };
@@ -286,9 +285,8 @@ impl RepoListStore {
} }
} }
// Popularity counts per repository (issues, pull requests and // Popularity counts per repository, issues, pull requests and patches.
// patches). Unbounded, unlike the windowed activity query // Unbounded, unlike the windowed activity query above, so totals are exact.
// above, so totals are exact.
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new(); let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter = let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
@@ -297,8 +295,8 @@ impl RepoListStore {
continue; continue;
} }
for addr in event.tags.coordinates() { for addr in event.tags.coordinates() {
// Skip events for repos we don't list, so the map can't // Skip events for repos we do not list.
// grow beyond the number of announcements. // The map cannot grow beyond the number of announcements.
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
{ {
continue; continue;
@@ -319,7 +317,7 @@ impl RepoListStore {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let (announcements, last_activity, counts) = match work.await { let (announcements, last_activity, counts) = match work.await {
Ok(results) => results, Ok(results) => results,
// Database errors are transient; keep the last list. // Database errors are transient, keep the last list.
Err(_) => { Err(_) => {
return this.update(cx, |this, _cx| { return this.update(cx, |this, _cx| {
this.refreshing = false; this.refreshing = false;
@@ -342,8 +340,8 @@ impl RepoListStore {
} }
})?; })?;
// Requests that arrived while the refresh was running are // Requests that arrived while the refresh was running.
// coalesced into one follow-up refresh. // They are coalesced into one follow-up refresh.
if again { if again {
this.update(cx, |this, cx| this.refresh(cx))?; this.update(cx, |this, cx| this.refresh(cx))?;
} }
+7 -6
View File
@@ -4,8 +4,8 @@ use gpui_component::clipboard::Clipboard;
use gpui_component::menu::PopupMenuItem; use gpui_component::menu::PopupMenuItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex};
/// A muted command row with a copy button: the value in a mono-friendly, /// A muted command row with a copy button.
/// truncated line, with a [`Clipboard`] button copying the full value. /// The value renders truncated and a [`Clipboard`] button copies the full value.
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
where where
E: Into<ElementId>, E: Into<ElementId>,
@@ -34,10 +34,11 @@ where
) )
} }
/// One row of a copy menu: a small title above the compact label, with /// One row of a copy menu, with a small title above the compact label.
/// a copy button that flips to a check while the value is on the clipboard. /// A copy button flips to a check while the value is on the clipboard.
/// Clicking the row copies and dismisses the menu; the copy button stops /// Clicking the row copies and dismisses the menu.
/// propagation so the menu stays open. Both copy `copy`, never the label. /// The copy button stops propagation so the menu stays open.
/// Both copy `copy`, never the label.
pub fn menu_copy_row( pub fn menu_copy_row(
id: &'static str, id: &'static str,
title: &'static str, title: &'static str,
+20 -20
View File
@@ -7,11 +7,10 @@ use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
use gpui_component::menu::PopupMenu; use gpui_component::menu::PopupMenu;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
/// A split dropdown button built on `gpui_base::Popover`: an action element /// A split dropdown button built on `gpui_base::Popover`.
/// with a separate caret trigger that opens a [`PopupMenu`]. /// An action element with a separate caret trigger that opens a [`PopupMenu`].
/// /// The action and the caret are caller-supplied elements, so the look stays in the app.
/// The action and the caret are caller-supplied elements, so the look stays /// This component only owns the popover wiring.
/// in the application; this component only owns the popover wiring.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct DropdownButton { pub struct DropdownButton {
id: ElementId, id: ElementId,
@@ -38,15 +37,16 @@ impl DropdownButton {
} }
} }
/// The action half of the button. It keeps its own icon, label, tooltip /// The action half of the button.
/// and click handler. /// It keeps its own icon, label, tooltip and click handler.
pub fn action(mut self, action: impl IntoElement + 'static) -> Self { pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
self.action = Some(action.into_any_element()); self.action = Some(action.into_any_element());
self self
} }
/// The menu built by `builder` — the same signature as gpui-component's /// The menu built by `builder`.
/// `DropdownButton::dropdown_menu`, so existing menu code keeps working. /// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
/// Existing menu code keeps working.
pub fn dropdown_menu( pub fn dropdown_menu(
mut self, mut self,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
@@ -55,10 +55,9 @@ impl DropdownButton {
self self
} }
/// Which corner of the caret the menu anchors to. Defaults to /// Which corner of the caret the menu anchors to.
/// [`Anchor::TopRight`], so the menu's right edge lines up with the /// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
/// caret's. #[allow(dead_code)] // API knob, current call sites use the default anchor.
#[allow(dead_code)] // API knob; current call sites use the default anchor.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self { pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into(); self.anchor = anchor.into();
self self
@@ -71,8 +70,8 @@ impl Styled for DropdownButton {
} }
} }
/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal /// Holds the [`PopupMenu`] entity of one popover between renders.
/// drops it, so the menu is rebuilt with fresh items on the next open. /// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
#[derive(Default)] #[derive(Default)]
struct DropdownMenuState { struct DropdownMenuState {
menu: Option<Entity<PopupMenu>>, menu: Option<Entity<PopupMenu>>,
@@ -85,7 +84,8 @@ impl RenderOnce for DropdownButton {
"a DropdownButton needs a `dropdown_menu`" "a DropdownButton needs a `dropdown_menu`"
); );
// The popover needs its own id: both the container and the popover register keyed state on this window. // The popover needs its own id.
// The container and the popover both register keyed state on this window.
let popover_id = SharedString::from(format!("{}-popover", self.id)); let popover_id = SharedString::from(format!("{}-popover", self.id));
let anchor = self.anchor; let anchor = self.anchor;
let menu_state = let menu_state =
@@ -109,8 +109,8 @@ impl RenderOnce for DropdownButton {
this.child( this.child(
Popover::new(popover_id) Popover::new(popover_id)
.anchor(anchor) .anchor(anchor)
// The menu dismisses itself on outside click or Escape; // The menu dismisses itself on outside click or Escape.
// the subscription below closes the popover along with it. // The subscription below closes the popover along with it.
.overlay_closable(false) .overlay_closable(false)
.trigger_with(caret) .trigger_with(caret)
.content( .content(
@@ -148,8 +148,8 @@ impl RenderOnce for DropdownButton {
} }
} }
/// The default caret: a chevron button the height of a medium button, tinted /// The default caret, a chevron button the height of a medium button.
/// by the theme, with hover and menu-open states. /// It is tinted by the theme and styled for hover and menu-open states.
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton { fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
BaseButton::new(id) BaseButton::new(id)
.h(px(32.)) .h(px(32.))
+2 -2
View File
@@ -7,8 +7,8 @@ use gpui::{
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash, ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
}; };
/// Default number of images each view's cache retains. Loading a new image /// Default number of images each view's cache retains.
/// evicts the least recently used entry once this is reached. /// Loading a new image evicts the least recently used entry once this is reached.
pub const MAX_IMAGES: usize = 128; pub const MAX_IMAGES: usize = 128;
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider { pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider {
-30
View File
@@ -1,33 +1,3 @@
//! Reusable UI components and elements for Signed.
//!
//! Everything here is presentation-only: the components read theme tokens
//! through [`gpui_component::ActiveTheme`] and render with plain GPUI
//! elements, so any view in the app can use them without depending on app
//! state. They are built on the unstyled `gpui-base` primitives and the
//! styled `gpui-component` library.
//!
//! The crate is organized by component:
//!
//! - [`PixelAvatar`] — deterministic, offline "pixel art" avatar
//! - [`NavItem`] — sidebar navigation row (leading element + label + suffix)
//! - [`DropdownButton`] — split button: an action plus a caret that opens a
//! [`PopupMenu`], wired through `gpui_base::Popover`
//! - [`SegmentButton`] / [`CountBadge`] — segmented filter/tab button with an
//! optional count badge
//! - [`UserAvatar`] — user picture avatar with a name-initials fallback
//! - [`status_badge`] — NIP-34 issue/PR status badge
//! - [`placeholder`] — centered muted placeholder message
//! - [`copy_row`] / [`menu_copy_row`] — rows with a copy-to-clipboard button
//! - [`tree_row`] — one row of a file tree
//! - [`setting_row`] / [`setting_block`] — label + description rows for a
//! settings dialog, with the control on the right (row) or below (block)
//! - [`SelectOption`] — dropdown option with a display label and a stored
//! value
//! - [`title_bar_drag_handlers`] — make an element behave like a window
//! title bar (drag moves the window, double-click zooms)
//! - [`image_cache`] — per-view LRU image cache provider
//! - [`middle_truncate`] — `[head]...[tail]` string truncation
mod dropdown_button; mod dropdown_button;
mod nav_item; mod nav_item;
mod pixel_avatar; mod pixel_avatar;
+5 -4
View File
@@ -2,9 +2,10 @@ use gpui::prelude::*;
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div}; use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
use gpui_component::{ActiveTheme, StyledExt, h_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex};
/// A single navigation entry in a sidebar: an arbitrary leading element /// A single navigation entry in a sidebar.
/// (an icon, avatar, ...) and a text label with a hover highlight, /// It has an arbitrary leading element, such as an icon or avatar, and a text label.
/// an optional trailing suffix (e.g. a status icon) and an optional click handler. /// Hover highlights the row.
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct NavItem { pub struct NavItem {
@@ -12,7 +13,7 @@ pub struct NavItem {
style: StyleRefinement, style: StyleRefinement,
icon: gpui::AnyElement, icon: gpui::AnyElement,
label: SharedString, label: SharedString,
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label. /// Trailing element at the right edge of the row, after the ellipsized label.
suffix: Option<gpui::AnyElement>, suffix: Option<gpui::AnyElement>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
} }
+15 -12
View File
@@ -9,13 +9,15 @@ const GRID_SIZE: usize = 8;
const FILL_PROBABILITY: f32 = 0.42; const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color. /// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25; const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a sparse roll still yields a /// Minimum number of filled left-half cells.
/// recognizable shape (each left-half cell is mirrored to a right-half one). /// A sparse roll still yields a recognizable shape.
/// Each left-half cell is mirrored to a right-half one.
const MIN_FILLED: usize = 5; const MIN_FILLED: usize = 5;
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal /// A deterministic, offline pixel-art avatar.
/// mirror symmetry, seeded from a stable string such as the repository id and /// An 8×8 grid with horizontal mirror symmetry.
/// owner public key. The same seed always renders the same avatar. /// Seeded from a stable string such as the repository id and owner public key.
/// The same seed always renders the same avatar.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct PixelAvatar { pub struct PixelAvatar {
seed: u64, seed: u64,
@@ -24,8 +26,8 @@ pub struct PixelAvatar {
} }
impl PixelAvatar { impl PixelAvatar {
/// Create an avatar seeded from `seed`. The seed should be a stable string /// Create an avatar seeded from `seed`.
/// unique to the entity the avatar represents. /// The seed should be a stable string unique to the entity the avatar represents.
pub fn new(seed: impl AsRef<str>) -> Self { pub fn new(seed: impl AsRef<str>) -> Self {
Self { Self {
seed: fnv1a(seed.as_ref().as_bytes()), seed: fnv1a(seed.as_ref().as_bytes()),
@@ -85,8 +87,9 @@ impl RenderOnce for PixelAvatar {
} }
} }
/// Generate the 8×8 cell pattern for `seed`. Cells are `0` (empty), `1` /// Generate the 8×8 cell pattern for `seed`.
/// (main color) or `2` (accent shade); the right half mirrors the left half. /// Cells are `0` for empty, `1` for main color and `2` for accent shade.
/// The right half mirrors the left half.
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] { fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut rng = PixelRng::new(seed); let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
@@ -102,8 +105,8 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
} }
} }
// Sparse rolls can come out nearly empty; top the pattern up to the // Sparse rolls can come out nearly empty.
// minimum fill, scanning from a seeded starting cell. // Top the pattern up to the minimum fill, scanning from a seeded starting cell.
if filled < MIN_FILLED { if filled < MIN_FILLED {
let half = GRID_SIZE * GRID_SIZE / 2; let half = GRID_SIZE * GRID_SIZE / 2;
let start = (rng.next() % half as u64) as usize; let start = (rng.next() % half as u64) as usize;
@@ -130,7 +133,7 @@ fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, v
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value; pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
} }
/// FNV-1a 64-bit hash; stable across platforms and runs. /// FNV-1a 64-bit hash, stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 { fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64; let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes { for &byte in bytes {
+9 -10
View File
@@ -3,9 +3,9 @@ use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, di
use gpui_base::{Button as BaseButton, StyledExt}; use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::ActiveTheme; use gpui_component::ActiveTheme;
/// A small count badge shown after a label, e.g. on a segmented filter /// A small count badge shown after a label.
/// button ("All 12") or a tab. Rendered from theme tokens; sized for the /// Used on segmented filter buttons, like `All 12`, and on tabs.
/// compact header buttons it lives on. /// Rendered from theme tokens and sized for the compact header buttons it lives on.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct CountBadge { pub struct CountBadge {
count: usize, count: usize,
@@ -46,12 +46,11 @@ impl RenderOnce for CountBadge {
} }
} }
/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`] /// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
/// and a selected (pressed) state, styled from the theme's button tokens. /// The selected state renders the button pressed, styled from theme button tokens.
/// /// Built on the unstyled `gpui_base::Button`, like the app's other custom controls.
/// Built on the unstyled `gpui_base::Button`, like the app's other custom /// The `primary` variant uses the primary button tokens.
/// controls; the `primary` variant uses the primary button tokens for /// It suits call-to-action buttons such as `New issue` and `New PR`.
/// call-to-action buttons ("New issue", "New PR").
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct SegmentButton { pub struct SegmentButton {
@@ -101,7 +100,7 @@ impl SegmentButton {
self self
} }
/// Use the primary button tokens (for call-to-action buttons). /// Use the primary button tokens, for call-to-action buttons.
pub fn primary(mut self) -> Self { pub fn primary(mut self) -> Self {
self.primary = true; self.primary = true;
self self
+5 -9
View File
@@ -1,15 +1,11 @@
//! Reusable settings UI: rows and blocks for building a settings dialog, plus
//! a labeled dropdown option.
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{App, SharedString, div}; use gpui::{App, SharedString, div};
use gpui_component::searchable_list::SearchableListItem; use gpui_component::searchable_list::SearchableListItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
/// A dropdown option with a display label and a stored value. /// A dropdown option with a display label and a stored value.
/// /// The trigger and menu render the `label`.
/// Renders the `label` in the trigger and the menu, while `value` is what a /// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
/// [`gpui_component::select::SelectState`] reports as the selection.
#[derive(Clone)] #[derive(Clone)]
pub struct SelectOption { pub struct SelectOption {
value: SharedString, value: SharedString,
@@ -48,7 +44,7 @@ impl SearchableListItem for SelectOption {
} }
} }
/// A settings row: label + description on the left, control on the right. /// A settings row with the label and description on the left and the control on the right.
pub fn setting_row( pub fn setting_row(
cx: &App, cx: &App,
title: impl Into<SharedString>, title: impl Into<SharedString>,
@@ -85,8 +81,8 @@ pub fn setting_row(
) )
} }
/// A full-width settings block: title + subtitle in one header, `gap_3` /// A full-width settings block with title and subtitle in one header.
/// between the header and the control below. /// `gap_3` separates the header from the control below.
pub fn setting_block( pub fn setting_block(
cx: &App, cx: &App,
title: impl Into<SharedString>, title: impl Into<SharedString>,
+2 -2
View File
@@ -5,8 +5,8 @@ use gpui_component::tooltip::Tooltip;
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex}; use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
use signed_core::RepoStatus; use signed_core::RepoStatus;
/// The status badge shown next to an issue or pull request: icon + colored square, /// The status badge shown next to an issue or pull request.
/// with a tooltip describing the status. /// It has an icon and a colored square, with a tooltip describing the status.
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status { let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => ( RepoStatus::Open => (
+6 -6
View File
@@ -8,12 +8,12 @@ struct WindowDragState {
should_move: bool, should_move: bool,
} }
/// Make an element behave like a window title bar: dragging it moves the /// Make an element behave like a window title bar.
/// window, and double-clicking zooms the window (or performs the platform's /// Dragging it moves the window.
/// default title-bar double-click action on macOS). /// Double-clicking zooms the window.
/// /// On macOS it runs the platform's default title-bar double-click action.
/// Only the bar's non-interactive areas should get this — tabs are draggable /// Only the bar's non-interactive areas should get this.
/// (to reorder panels) and must not move the window. /// Tabs are draggable to reorder panels and must not move the window.
pub fn title_bar_drag_handlers( pub fn title_bar_drag_handlers(
this: Stateful<Div>, this: Stateful<Div>,
window: &mut Window, window: &mut Window,
+3 -2
View File
@@ -4,8 +4,9 @@ use gpui_component::list::ListItem;
use gpui_component::tree::TreeEntry; use gpui_component::tree::TreeEntry;
use gpui_component::{Icon, IconName, Sizable, h_flex}; use gpui_component::{Icon, IconName, Sizable, h_flex};
/// One row of a file tree: icon + name, indented by depth. /// One row of a file tree, an icon and a name indented by depth.
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself. /// Clicking a file runs `on_click`.
/// Folders expand and collapse via the tree itself.
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where where
F: Fn(&mut Window, &mut App) + 'static, F: Fn(&mut Window, &mut App) + 'static,
+4 -4
View File
@@ -3,8 +3,8 @@ use gpui::{App, SharedString, StyleRefinement, Window};
use gpui_component::avatar::Avatar; use gpui_component::avatar::Avatar;
use gpui_component::{ActiveTheme, Sizable, StyledExt}; use gpui_component::{ActiveTheme, Sizable, StyledExt};
/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with /// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
/// the theme radius, showing the user's picture or a name-initials fallback. /// It shows the user's picture or falls back to name initials.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct UserAvatar { pub struct UserAvatar {
name: SharedString, name: SharedString,
@@ -13,8 +13,8 @@ pub struct UserAvatar {
} }
impl UserAvatar { impl UserAvatar {
/// Create an avatar for `name`; the name seeds the initials fallback /// Create an avatar for `name`.
/// shown when no picture is set. /// The name seeds the initials fallback shown when no picture is set.
pub fn new(name: impl Into<SharedString>) -> Self { pub fn new(name: impl Into<SharedString>) -> Self {
Self { Self {
name: name.into(), name: name.into(),
+3 -3
View File
@@ -1,5 +1,5 @@
/// `[head chars]...[tail chars]` middle truncation; the value is left alone /// `[head chars]...[tail chars]` middle truncation.
/// when it is too short for the ellipsis to save space. /// Values too short for the ellipsis to save space are left alone.
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
let len = value.chars().count(); let len = value.chars().count();
if len <= head + tail + 3 { if len <= head + tail + 3 {
@@ -32,7 +32,7 @@ mod tests {
), ),
"30617:a008...3564d:ngit" "30617:a008...3564d:ngit"
); );
// Too short to save space with the ellipsis: left alone. // Too short to save space with the ellipsis, left alone.
assert_eq!(middle_truncate("short", 10, 10), "short"); assert_eq!(middle_truncate("short", 10, 10), "short");
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
use nostr::prelude::*; use nostr::prelude::*;
/// Format a timestamp as a short relative time (e.g. "3h ago"). /// Format a timestamp as a short relative time, e.g. `3h ago`.
pub fn relative_time(timestamp: Timestamp) -> String { pub fn relative_time(timestamp: Timestamp) -> String {
let now = Timestamp::now().as_secs(); let now = Timestamp::now().as_secs();
let secs = now.saturating_sub(timestamp.as_secs()); let secs = now.saturating_sub(timestamp.as_secs());
@@ -20,7 +20,7 @@ pub fn relative_time(timestamp: Timestamp) -> String {
} }
} }
/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago"). /// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
pub fn relative_time_secs(secs: i64) -> String { pub fn relative_time_secs(secs: i64) -> String {
relative_time(Timestamp::from_secs(secs.max(0) as u64)) relative_time(Timestamp::from_secs(secs.max(0) as u64))
} }
@@ -7,8 +7,8 @@ use signed_core::Announcement;
use signed_state::ProfileStore; use signed_state::ProfileStore;
use signed_ui::{UserAvatar, middle_truncate}; use signed_ui::{UserAvatar, middle_truncate};
/// Open the "About" dialog: every field of the repository's announcement /// Open the About dialog showing every field of the announcement event.
/// event (NIP-34, kind 30617), as parsed into [`Announcement`]. /// The event is NIP-34 kind 30617, parsed into [`Announcement`].
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| { window.open_dialog(cx, move |dialog, _window, cx| {
let announcement = announcement.clone(); let announcement = announcement.clone();
@@ -22,8 +22,8 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
}); });
} }
/// The announcement's fields as labeled rows; hex identifiers carry a copy /// The announcement's fields as labeled rows.
/// button, multi-value tags one line per value. /// Hex identifiers carry a copy button, multi-value tags one line per value.
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new(); let mut rows: Vec<AnyElement> = Vec::new();
@@ -116,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
v_flex().gap_3().w_full().children(rows).into_any_element() v_flex().gap_3().w_full().children(rows).into_any_element()
} }
/// One info row: a small muted label above the value. /// One info row with a small muted label above the value.
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement { fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex() v_flex()
.gap_1() .gap_1()
@@ -159,8 +159,9 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
/// One row per maintainer: avatar and display name (falling back to a /// One row per maintainer with avatar and display name.
/// shortened npub), with a copy button for the full pubkey. /// The display name falls back to a shortened npub.
/// A copy button copies the full pubkey.
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx); let profile_store = ProfileStore::global(cx);
v_flex() v_flex()
@@ -190,8 +191,8 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
/// One row per item of a multi-value tag: the value is truncated to a single /// One row per item of a multi-value tag.
/// line, with a copy button that copies the full value. /// The value is truncated to a single line, with a copy button for the full value.
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement { fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex() v_flex()
.gap_2() .gap_2()
@@ -16,8 +16,8 @@ use super::helpers::{code_language, is_markdown_path};
const TREE_WIDTH: f32 = 240.; const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed. /// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps: at most this many files (or this many text bytes) /// Preview cache caps, a file count and a text byte count.
/// are kept in memory at once; the oldest previews are evicted beyond that. /// The oldest previews are evicted beyond the caps.
pub(super) const MAX_PREVIEWED_FILES: usize = 32; pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
@@ -34,19 +34,19 @@ pub(super) enum FileContent {
} }
/// A markdown document loaded into a persistent [`TextViewState`]. /// A markdown document loaded into a persistent [`TextViewState`].
/// /// The state lives in the view rather than being created per render.
/// The state is owned by the view rather than created per render: GPUI /// GPUI drops keyed element state after one absent frame.
/// drops keyed element state after one absent frame, which would re-parse /// A per-render state would re-parse the whole document on every pane switch.
/// the whole document on every pane switch (README / file / spinner).
pub(super) struct MarkdownView { pub(super) struct MarkdownView {
/// Source path; `None` means the repository README. /// Source path, `None` means the repository README.
pub(super) path: Option<SharedString>, pub(super) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>, pub(super) state: Entity<TextViewState>,
} }
/// A code file loaded into a persistent [`InputState`], rendered as a /// A code file loaded into a persistent [`InputState`].
/// disabled (read-only) code editor with syntax highlighting, line numbers /// It renders as a disabled, read-only code editor.
/// and search. Persistent for the same reason as [`MarkdownView`]. /// Syntax highlighting, line numbers and search are included.
/// Persistent for the same reason as [`MarkdownView`].
pub(super) struct CodeView { pub(super) struct CodeView {
/// Source path, relative to the worktree root. /// Source path, relative to the worktree root.
pub(super) path: SharedString, pub(super) path: SharedString,
@@ -64,7 +64,7 @@ fn preview_spinner() -> AnyElement {
} }
impl RepoDetailView { impl RepoDetailView {
/// One row of the file tree: icon + name, indented by depth. /// One row of the file tree with icon and name, indented by depth.
fn render_tree_item( fn render_tree_item(
ix: usize, ix: usize,
entry: &TreeEntry, entry: &TreeEntry,
@@ -81,7 +81,7 @@ impl RepoDetailView {
}) })
} }
/// Left column: the file tree. /// Left column showing the file tree.
pub(super) fn render_tree_column( pub(super) fn render_tree_column(
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
view: WeakEntity<Self>, view: WeakEntity<Self>,
@@ -102,7 +102,7 @@ impl RepoDetailView {
))) )))
} }
/// Right column: README, selected file preview, or status text. /// Right column, README, selected file preview or status text.
pub(super) fn render_content_column( pub(super) fn render_content_column(
&self, &self,
pane_title: SharedString, pane_title: SharedString,
@@ -159,9 +159,8 @@ impl RepoDetailView {
placeholder("No README found", cx) placeholder("No README found", cx)
}; };
// Latest commit for the current pane: the selected file, or the README // Latest commit for the current pane, the selected file or the README.
// while nothing is selected. Computed after the body above, which // Computed after the body above, which needs `&mut self`.
// needs `&mut self`.
let commit = match &self.selected_file { let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()), Some(path) => self.commits.get(path.as_ref()),
None => self None => self
@@ -217,9 +216,8 @@ impl RepoDetailView {
} }
/// Load `text` into the persistent markdown TextView state. /// Load `text` into the persistent markdown TextView state.
/// /// The state is created empty and fed via `push_str`, which parses on a background task.
/// The state is created empty and fed via `push_str`, which parses on a /// Switching files never blocks the main thread.
/// background task, so switching files never blocks the main thread.
pub(super) fn set_markdown( pub(super) fn set_markdown(
&mut self, &mut self,
path: Option<SharedString>, path: Option<SharedString>,
@@ -231,8 +229,8 @@ impl RepoDetailView {
self.md = Some(MarkdownView { path, state }); self.md = Some(MarkdownView { path, state });
} }
/// The persistent markdown TextView for `path` (`None` = README), or a /// The persistent markdown TextView for `path`, where `None` is the README.
/// spinner while the document is being loaded/parsed. /// Shows a spinner while the document is being loaded or parsed.
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement { fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
let Some(md) = &self.md else { let Some(md) = &self.md else {
return preview_spinner(); return preview_spinner();
@@ -254,10 +252,8 @@ impl RepoDetailView {
} }
/// Load `text` into the persistent code editor state for `path`. /// Load `text` into the persistent code editor state for `path`.
/// /// Code editor mode makes the Input render it read-only and highlighted.
/// The state is created in code editor mode so the Input renders it as /// The tree-sitter parse runs on a background task like [`set_markdown`]'s.
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// on a background task like [`set_markdown`]'s.
pub(super) fn set_code( pub(super) fn set_code(
&mut self, &mut self,
path: SharedString, path: SharedString,
@@ -276,8 +272,7 @@ impl RepoDetailView {
self.code = Some(CodeView { path, state }); self.code = Some(CodeView { path, state });
} }
/// The persistent code editor for `path`, or a spinner while the file is /// The persistent code editor for `path`, or a spinner while the file loads or parses.
/// being loaded/parsed.
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement { fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else { let Some(code) = &self.code else {
return preview_spinner(); return preview_spinner();
@@ -70,8 +70,8 @@ pub(super) fn commit_row(
} }
impl RepoDetailView { impl RepoDetailView {
/// Full-height body of the Commits tab: all commits in a virtual /// Full-height body of the Commits tab.
/// list, or a status message while loading / when there are none. /// All commits in a virtual list, or a status message while loading or empty.
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement { pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(list) = self.all_commits.as_ref() else { let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits { return if self.loading_all_commits {
@@ -90,9 +90,9 @@ impl RepoDetailView {
return placeholder("No commits found", cx); return placeholder("No commits found", cx);
} }
// Copy only the values the element tree needs; the list itself is // Copy only the values the element tree needs.
// borrowed inside the renderer below instead of being cloned per // The list is borrowed by the renderer below instead of cloned per frame.
// frame (a full history can be tens of thousands of commits). // A full history can be tens of thousands of commits.
let view = cx.entity().clone(); let view = cx.entity().clone();
let sizes = self.item_sizes.clone(); let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
@@ -137,7 +137,8 @@ impl RepoDetailView {
.size_full(), .size_full(),
) )
.when(shown < total, |this| { .when(shown < total, |this| {
// The history is capped; tell the user the list is truncated. // The history is capped.
// Tell the user the list is truncated.
this.child( this.child(
div() div()
.py_2() .py_2()
+26 -28
View File
@@ -28,19 +28,18 @@ use super::helpers::{
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// The tree + per-file diff body shared by the commit diff panel and the /// Tree and per-file diff body, shared by the commit diff and compare views.
/// compare view of the new-pull-request panel. Owns the changed-files /// Owns the changed-files explorer and the virtual list of the selected file's hunks.
/// explorer and the virtual list of the selected file's hunks; the host /// The host feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
/// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
pub struct DiffPane { pub struct DiffPane {
/// Loaded diff; `None` until [`Self::set_diff`] is called. /// Loaded diff, `None` until [`Self::set_diff`] is called.
diff: Option<CommitDiff>, diff: Option<CommitDiff>,
/// Changed-files explorer state. /// Changed-files explorer state.
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column. /// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>, selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines), backing the /// Rows of the selected file's diff, hunk headers and lines.
/// virtual list in the detail column. /// Backing the virtual list in the detail column.
rows: Vec<DiffRow>, rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`]. /// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
@@ -90,8 +89,8 @@ impl DiffPane {
} }
} }
/// Forget the diff (e.g. when the compared branches changed): clear the /// Forget the diff, e.g. when the compared branches changed.
/// tree, the selection and the diff rows. /// Clears the tree, the selection and the diff rows.
pub fn clear(&mut self, cx: &mut Context<Self>) { pub fn clear(&mut self, cx: &mut Context<Self>) {
self.diff = None; self.diff = None;
self.selected_file = None; self.selected_file = None;
@@ -102,15 +101,14 @@ impl DiffPane {
}); });
} }
/// Show the diff of the file at `path` (selected in the tree). /// Show the diff of the file at `path`, selected in the tree.
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) { fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
self.set_diff_rows(path); self.set_diff_rows(path);
cx.notify(); cx.notify();
} }
/// Rebuild the virtual list state for the file at `path` and scroll back /// Rebuild the virtual list state for `path` and scroll back to the top.
/// to the top.
fn set_diff_rows(&mut self, path: &str) { fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return; return;
@@ -123,7 +121,7 @@ impl DiffPane {
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
} }
/// One row of the changed-files tree: icon + name, indented by depth. /// One row of the changed-files tree, icon and name, indented by depth.
fn render_tree_item( fn render_tree_item(
ix: usize, ix: usize,
entry: &TreeEntry, entry: &TreeEntry,
@@ -140,7 +138,7 @@ impl DiffPane {
}) })
} }
/// Left column: the changed-files tree. /// Left column showing the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement { fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone(); let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
@@ -170,7 +168,7 @@ impl DiffPane {
.into_any_element() .into_any_element()
} }
/// Right column: header of the selected file plus its diff. /// Right column, header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement { fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return placeholder("No changes", cx); return placeholder("No changes", cx);
@@ -188,8 +186,9 @@ impl DiffPane {
self.render_file_diff(file, cx.entity(), cx) self.render_file_diff(file, cx.entity(), cx)
} }
/// The diff of one file: a header with status and stats, then the hunks /// The diff of one file, with a header showing status and stats.
/// in a virtual list (a large diff is never materialized per frame). /// The hunks render in a virtual list.
/// A large diff is never materialized per frame.
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement { fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status { let status_label = match file.status {
DiffStatus::Added => "A", DiffStatus::Added => "A",
@@ -316,25 +315,24 @@ impl Render for DiffPane {
} }
} }
/// Detail panel showing the diff of one commit: a metadata header plus the /// Detail panel showing the diff of one commit.
/// shared [`DiffPane`] body. /// A metadata header plus the shared [`DiffPane`] body.
pub struct CommitDiffView { pub struct CommitDiffView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Local clone the commit lives in. /// Local clone the commit lives in.
worktree: PathBuf, worktree: PathBuf,
/// Display name of the repository the commit belongs to. /// Display name of the repository the commit belongs to.
repo_name: SharedString, repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only /// The commit being shown in the header and tab title.
/// stub; [`Self::load`] replaces it with the full metadata, which the /// Starts as an id-only stub, the history list omits the full metadata.
/// history list intentionally omits. /// [`Self::load`] replaces the stub with the full metadata.
commit: FileCommit, commit: FileCommit,
/// The diff is being computed on a background task. /// The diff is being computed on a background task.
loading: bool, loading: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Changed-files explorer and per-file diff, shared with the compare /// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
/// view of the new-pull-request panel.
pane: Entity<DiffPane>, pane: Entity<DiffPane>,
/// In-flight tasks; pruned on every push (see [`helpers::track`]). /// In-flight tasks, pruned on every push, see [`helpers::track`].
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>, tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
} }
@@ -371,8 +369,8 @@ impl CommitDiffView {
} }
} }
/// Load the commit diff (and the full commit metadata) on a background /// Load the commit diff and the full commit metadata on a background task.
/// task and populate the tree. /// Then populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -419,7 +417,7 @@ impl CommitDiffView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Header: commit id, summary, author/time and overall change stats. /// Header with the commit id, summary, author/time and overall change stats.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit; let commit = &self.commit;
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| { let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
@@ -11,9 +11,9 @@ use signed_core::Announcement;
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
use signed_ui::{menu_copy_row, middle_truncate}; use signed_ui::{menu_copy_row, middle_truncate};
/// A `Send` file-tree node: the tree is built on a background thread and /// A `Send` file-tree node, the build runs on a background thread.
/// converted into [`TreeItem`]s (which hold `Rc` state, /// The main thread converts the seeds into [`TreeItem`]s.
/// so they cannot cross threads) on the main thread. /// [`TreeItem`]s hold `Rc` state and cannot cross threads.
pub(super) struct TreeItemSeed { pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root. /// Path of the node, relative to the worktree root.
id: String, id: String,
@@ -22,12 +22,10 @@ pub(super) struct TreeItemSeed {
children: Vec<TreeItemSeed>, children: Vec<TreeItemSeed>,
} }
/// Convert tree seeds into [`TreeItem`]s, expanding every folder /// Convert tree seeds into [`TreeItem`]s.
/// when `expand_folders` is set. /// Every folder is expanded when `expand_folders` is set.
/// /// The commit diff explorer shows only changed files, typically a handful.
/// The commit diff explorer shows only changed files, /// Its folders start expanded, the worktree explorer's folders collapsed.
/// which is typically a handful of paths, so its folders start expanded;
/// the worktree explorer starts collapsed instead.
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> { pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label); let mut item = TreeItem::new(seed.id, seed.label);
@@ -48,14 +46,13 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
.collect() .collect()
} }
/// Build nested tree items from a flat, sorted (dirs-first) entry list. /// Build nested tree items from a flat entry list sorted dirs-first.
/// /// Returns [`TreeItemSeed`]s so the build can run off the main thread.
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a /// A worktree walk can yield tens of thousands of entries.
/// worktree walk can yield tens of thousands of entries. Nodes live in an /// Nodes live in an arena, parents are found via a path-to-index map.
/// arena and parents are found via a path -> index map, which keeps the /// That keeps the build linear in the number of path components.
/// build linear in the number of path components.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> { pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, for O(1) parent lookup while inserting. // Node indices by full path, so parents resolve in constant time while inserting.
let mut index: HashMap<String, usize> = HashMap::new(); let mut index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new(); let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: Vec<usize> = Vec::new(); let mut roots: Vec<usize> = Vec::new();
@@ -99,9 +96,8 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
} }
/// The markdown fence language for a file path, or `None` for plain text. /// The markdown fence language for a file path, or `None` for plain text.
/// /// Names resolve in `gpui_component`'s highlighter.
/// Names are chosen so `gpui_component`'s highlighter can resolve them /// `highlighter::Language::from_name` accepts short aliases like `rs` and `js`.
/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`).
pub(super) fn code_language(path: &str) -> Option<&'static str> { pub(super) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path) let name = Path::new(path)
.file_name() .file_name()
@@ -166,7 +162,7 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
} }
pub(super) struct ShareTargets { pub(super) struct ShareTargets {
/// NIP-19 `naddr1...` of the announcement (with its announced relays). /// NIP-19 `naddr1...` of the announcement, with its announced relays.
pub(super) naddr: String, pub(super) naddr: String,
/// Hex ID of the announcement event itself. /// Hex ID of the announcement event itself.
pub(super) event_id: String, pub(super) event_id: String,
@@ -195,8 +191,8 @@ impl ShareTargets {
} }
} }
/// The share dropdown menu: one row per target, each showing a compact /// The share dropdown menu, one row per target.
/// label while the copy button (and row click) copy the full value. /// Each shows a compact label, the copy button and row click copy the full value.
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.)) menu.min_w(px(340.))
.item(menu_copy_row( .item(menu_copy_row(
@@ -226,9 +222,9 @@ impl ShareTargets {
} }
} }
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g. /// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened; /// `https://gitworkshop.dev/naddr1...abcd` is an example.
/// the value to be copied stays the full URL. /// Only the label is shortened, the copied value stays the full URL.
fn truncate_naddr_link(url: &str, tail: usize) -> String { fn truncate_naddr_link(url: &str, tail: usize) -> String {
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
return url.to_string(); return url.to_string();
@@ -244,7 +240,7 @@ pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list. /// Height of one row in a virtual diff list.
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of a virtual diff list: a hunk header, or a line of a hunk. /// One row of a virtual diff list, a hunk header or a line of a hunk.
/// Shared by the commit diff and pull request diff viewers. /// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(super) enum DiffRow { pub(super) enum DiffRow {
@@ -258,7 +254,7 @@ pub(super) enum DiffRow {
Line { hunk: usize, line: usize }, Line { hunk: usize, line: usize },
} }
/// The rows of `file`'s diff: one header row per hunk, then its lines. /// The rows of `file`'s diff, one header row per hunk then its lines.
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> { pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() { for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
@@ -276,7 +272,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
rows rows
} }
/// One row of the virtual diff list: a hunk header or a single line. /// One row of the virtual diff list, a hunk header or a single line.
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row { match row {
DiffRow::Hunk { DiffRow::Hunk {
@@ -303,8 +299,8 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any
} }
} }
/// One diff line: old and new line numbers in gutters, then the content, /// One diff line, old and new line numbers in the gutters.
/// tinted by kind (addition / deletion / context). /// The content is tinted by kind, addition, deletion or context.
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind { let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
@@ -313,8 +309,8 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
}; };
let gutter = cx.theme().muted_foreground; let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has // Fixed height and nowrap, the virtual list assumes every row has the same height.
// the same height, so long lines are clipped instead of wrapped. // Long lines are clipped instead of wrapped.
h_flex() h_flex()
.w_full() .w_full()
.h(px(DIFF_ROW_HEIGHT)) .h(px(DIFF_ROW_HEIGHT))
@@ -379,7 +375,7 @@ mod tests {
let items = build_tree_items(&entries); let items = build_tree_items(&entries);
// Input order is preserved (dirs-first, as produced by worktree_entries). // Input order is preserved, dirs-first as produced by worktree_entries.
assert_eq!(items.len(), 3); assert_eq!(items.len(), 3);
assert_eq!(items[0].label, "src"); assert_eq!(items[0].label, "src");
assert_eq!(items[0].id, "src"); assert_eq!(items[0].id, "src");
@@ -411,9 +407,9 @@ mod tests {
#[test] #[test]
fn tree_builder_merges_shared_prefixes() { fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories' // File children of a directory arrive after other directories' entries.
// entries (the worktree list is dirs-first globally); the shared // The worktree list is dirs-first globally.
// prefix must still resolve to one node. // The shared prefix must still resolve to one node.
let entries = vec![ let entries = vec![
PathBuf::from("a/x.txt"), PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"), PathBuf::from("b/y.txt"),
@@ -461,7 +457,7 @@ mod tests {
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4), truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
"https://gitworkshop.dev/naddr1...1234" "https://gitworkshop.dev/naddr1...1234"
); );
// No naddr1 prefix: unchanged. // Without the naddr1 prefix, unchanged.
assert_eq!( assert_eq!(
truncate_naddr_link("https://example.com/x", 4), truncate_naddr_link("https://example.com/x", 4),
"https://example.com/x" "https://example.com/x"
@@ -26,10 +26,9 @@ pub struct InitRepoState {
} }
/// Open the Init dialog for the local repository at `local_path`. /// Open the Init dialog for the local repository at `local_path`.
/// /// The dialog loads the user's default grasp servers, a kind `10317` grasp list.
/// The dialog loads the user's default grasp servers (kind `10317` grasp /// It falls back to the shared defaults when the user has none set.
/// list) and falls back to the shared defaults when none are set. On /// On success the dialog closes and `view` switches into NIP-34 mode.
/// success the dialog closes and `view` switches into NIP-34 mode.
pub fn open( pub fn open(
local_path: PathBuf, local_path: PathBuf,
view: WeakEntity<RepoDetailView>, view: WeakEntity<RepoDetailView>,
@@ -153,8 +152,8 @@ pub fn open(
}); });
} }
/// Run the init flow; closes the dialog and switches the repository into /// Run the init flow.
/// its NIP-34 mode on success. /// Closes the dialog and switches the repository into NIP-34 mode on success.
fn init_repository( fn init_repository(
local_path: PathBuf, local_path: PathBuf,
inputs: (Entity<InputState>, Entity<TextareaState>), inputs: (Entity<InputState>, Entity<TextareaState>),
@@ -25,7 +25,7 @@ pub struct IssueDetailView {
store: Entity<RepoStore>, store: Entity<RepoStore>,
issue_id: EventId, issue_id: EventId,
contents: HashMap<EventId, SharedString>, contents: HashMap<EventId, SharedString>,
/// Input state of the "leave a comment" textarea. /// Input state of the comment textarea.
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
} }
@@ -58,7 +58,7 @@ impl IssueDetailView {
return div().into_any_element(); return div().into_any_element();
}; };
// Participants: the issue author plus everyone who commented. // Participants, the issue author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![issue.pubkey]; let mut participants: Vec<PublicKey> = vec![issue.pubkey];
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey)); participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex); participants.sort_by_key(PublicKey::to_hex);
@@ -138,8 +138,7 @@ impl IssueDetailView {
let author = profile.name(); let author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per // Comment bodies become shared strings once per comment, not per render.
// comment, not on every render.
let content = self let content = self
.contents .contents
.entry(comment.id) .entry(comment.id)
@@ -24,8 +24,9 @@ use utils::relative_time;
use super::issue_detail::IssueDetailView; use super::issue_detail::IssueDetailView;
/// Height of one issue row in the virtual list: `py_2` padding, a 32px /// Height of one issue row in the virtual list.
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border. /// `py_2` padding, a 32px `h_8` title line and a 24px `h_6` meta line.
/// Plus the 1px bottom border.
const ISSUE_ROW_HEIGHT: f32 = 73.; const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons. /// Status filter of the issues list, chosen via the header's filter buttons.
@@ -35,8 +36,8 @@ enum IssueFilter {
All, All,
/// Issues whose resolved status is [`RepoStatus::Open`]. /// Issues whose resolved status is [`RepoStatus::Open`].
Open, Open,
/// Issues whose resolved status is [`RepoStatus::Closed`] or /// Issues whose resolved status is [`RepoStatus::Closed`].
/// [`RepoStatus::Applied`] (both are "done" states). /// [`RepoStatus::Applied`] counts too, both are done states.
Closed, Closed,
} }
@@ -63,14 +64,14 @@ pub struct IssuesView {
filter: IssueFilter, filter: IssueFilter,
/// Per-row heights of the virtual list. /// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count). /// The filtered issue count [`Self::item_sizes`] was built for.
issue_len: usize, issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`]; the /// Indices into the store's `issues` matching [`Self::filter`].
/// virtual list renders this slice. Rebuilt only when the store /// The virtual list renders this slice.
/// version or the filter changes, keyed by [`Self::cache_key`]. /// Rebuilt only when the store version or the filter changes.
/// Keyed by [`Self::cache_key`].
visible_issues: Vec<usize>, visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
/// [`Self::visible_issues`].
counts: (usize, usize, usize), counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from. /// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>, cache_key: Option<(u64, IssueFilter)>,
@@ -102,7 +103,7 @@ impl IssuesView {
} }
} }
/// Open the detail panel of `issue_id` at the bottom of the dock area. /// Open the detail panel of `issue_id` in the dock area.
fn open_issue_detail( fn open_issue_detail(
&mut self, &mut self,
issue_id: EventId, issue_id: EventId,
@@ -120,8 +121,8 @@ impl IssuesView {
}); });
} }
/// Render one row of the issue list; `ix` is the row index and /// Render one row of the issue list.
/// `issue_ix` the index of the issue in the store's `issues`. /// `ix` is the row index, `issue_ix` the index in the store's `issues`.
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement { fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let issue = &self.store.read(cx).issues[issue_ix]; let issue = &self.store.read(cx).issues[issue_ix];
let title = activity_subject(issue); let title = activity_subject(issue);
@@ -184,8 +185,8 @@ impl IssuesView {
} }
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild (`render` rebuilds first when the // Counts of the last list rebuild.
// store version or filter changed, so this is never stale). // `render` rebuilds first when the store version or filter changed, so never stale.
let (total, open, closed) = self.counts; let (total, open, closed) = self.counts;
h_flex() h_flex()
@@ -243,8 +244,8 @@ impl IssuesView {
} }
} }
/// Open the "new issue" dialog: a title and a content input that submit /// Open the new issue dialog, a title and a content input.
/// through [`RepoStore::open_issue`] when confirmed. /// Confirming submits through [`RepoStore::open_issue`].
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) { pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); 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..."));
@@ -333,8 +334,8 @@ impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter; let filter = self.filter;
// Rebuild the filtered rows and header counts only when the store // Rows and counts are rebuilt only when the store refreshed or filter changed.
// refreshed or the filter changed; other renders reuse the cache. // Other renders reuse the cache.
let version = self.store.read(cx).version(); let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) { if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx); let store = self.store.read(cx);
@@ -360,8 +361,8 @@ impl Render for IssuesView {
let count = self.visible_issues.len(); let count = self.visible_issues.len();
// The virtual list's item count comes from `item_sizes`; rebuild it // The virtual list's item count comes from `item_sizes`.
// whenever the filtered issue count changes. // Rebuild it whenever the filtered issue count changes.
if count != self.issue_len { if count != self.issue_len {
self.issue_len = count; self.issue_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
+241 -243
View File
@@ -66,35 +66,36 @@ use crate::views::repo_detail::new_pull_request::open_new_pull_panel;
/// What kind of ref the header selectors switch to. /// What kind of ref the header selectors switch to.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum RefKind { enum RefKind {
/// A local branch (`refs/heads/*`); HEAD stays attached. /// A local branch `refs/heads/*`, HEAD stays attached.
Branch, Branch,
/// A tag (`refs/tags/*`); HEAD becomes detached. /// A tag `refs/tags/*`, HEAD becomes detached.
Tag, Tag,
} }
/// Header actions dispatched by the dropdown menus of the header buttons. /// Header actions dispatched by the dropdown menus of the header buttons.
/// `pub(super)`: the pull-request list panel offers the same New-PR / Send- /// `pub(super)` because the pull-request list panel shares this action set.
/// patch actions in its own dropdown. /// It offers the New-PR and Send-patch actions in its own dropdown.
#[derive(Clone, Action, PartialEq, Eq)] #[derive(Clone, Action, PartialEq, Eq)]
#[action(namespace = repo_detail, no_json)] #[action(namespace = repo_detail, no_json)]
pub(super) enum RepoAction { pub(super) enum RepoAction {
/// Open the "new issue" dialog. /// Open the new issue dialog.
NewIssue, NewIssue,
/// Open the "new pull request" dialog. /// Open the new pull request dialog.
NewPR, NewPR,
/// Open the "send patch" panel. /// Open the send patch panel.
SendPatch, SendPatch,
/// Open the about dialog. /// Open the about dialog.
About, About,
/// Re-push the repository to its grasp servers. /// Re-push the repository to its grasp servers.
Push, Push,
/// Delete the repository from nostr (owner only). /// Delete the repository from nostr, owner only.
Delete, Delete,
} }
/// Everything loaded from the local clone for the explorer: the tree seeds, /// Everything loaded from the local clone for the explorer.
/// README, refs and HEAD commit. Computed on a background thread (see /// The tree seeds, README, refs and HEAD commit.
/// [`load_repo_data`]) and applied on the main thread. /// Computed on a background thread, see [`load_repo_data`].
/// Applied on the main thread.
struct RepoData { struct RepoData {
tree: Vec<TreeItemSeed>, tree: Vec<TreeItemSeed>,
readme_path: Option<PathBuf>, readme_path: Option<PathBuf>,
@@ -106,12 +107,13 @@ struct RepoData {
head_commit: Option<FileCommit>, head_commit: Option<FileCommit>,
} }
/// Derived NIP-34 header data, cached so renders don't re-encode bech32 /// Derived NIP-34 header data.
/// share targets and rebuild clone command strings on every frame. /// Renders avoid re-encoding bech32 share targets per frame.
/// They also avoid rebuilding the clone command strings.
struct HeaderCache { struct HeaderCache {
/// Announcement event ID and owner NIP-05 this cache was built from; /// Announcement event ID and owner NIP-05 this cache was built from.
/// rebuilt when either changes (a new announcement version, or the /// Rebuilt when either changes.
/// owner's profile arriving with a NIP-05 identifier). /// A new announcement version, or the owner's profile arriving with a NIP-05 identifier.
key: (EventId, Option<String>), key: (EventId, Option<String>),
announcement: Rc<Announcement>, announcement: Rc<Announcement>,
share: Rc<ShareTargets>, share: Rc<ShareTargets>,
@@ -120,54 +122,54 @@ struct HeaderCache {
git_commands: Rc<Vec<SharedString>>, git_commands: Rc<Vec<SharedString>>,
} }
/// Detail view of a repository: header, stats, a file explorer with README /// Detail view of a repository, header, stats and metadata.
/// preview (cloned from the announcement's `clone` URLs), and metadata. /// A file explorer with README preview, cloned from the announcement's `clone` URLs.
pub struct RepoDetailView { pub struct RepoDetailView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Dock area the detail view lives in; new panels (commit diffs) are /// Dock area the detail view lives in.
/// added there. /// New panels, commit diffs, are added there.
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
/// Snapshot taken at open time, shown until the store's first refresh /// Snapshot taken at open time.
/// completes (and as a fallback while the store has no announcement). /// Shown until the store's first refresh completes.
/// Also a fallback while the store has no announcement.
/// `None` for local repositories that haven't been published yet. /// `None` for local repositories that haven't been published yet.
initial: Option<Announcement>, initial: Option<Announcement>,
/// Per-repository nostr store (announcement, issues, PRs, statuses). /// Per-repository nostr store, holding announcement, issues, PRs and statuses.
/// `None` until a local repository is initialized (published) to /// `None` until a local repository is initialized to NIP-34.
/// NIP-34.
store: Option<Entity<RepoStore>>, store: Option<Entity<RepoStore>>,
/// Path of the local repository when opened from the scan; `None` once /// Path of the local repository when opened from the scan.
/// it has been initialized to NIP-34 (or for announced repositories). /// `None` once it is initialized to NIP-34, or for announced repositories.
local_path: Option<PathBuf>, local_path: Option<PathBuf>,
/// File explorer state (worktree of the local clone). /// File explorer state, the worktree of the local clone.
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Root of the local clone, for reading files on demand. /// Root of the local clone, for reading files on demand.
worktree: Option<PathBuf>, worktree: Option<PathBuf>,
/// Markdown document currently in the preview pane (README or a file). /// Markdown document currently in the preview pane, README or a file.
md: Option<MarkdownView>, md: Option<MarkdownView>,
/// Code file currently in the preview pane. /// Code file currently in the preview pane.
code: Option<CodeView>, code: Option<CodeView>,
readme_name: Option<SharedString>, readme_name: Option<SharedString>,
/// Currently previewed file (relative path) and its contents. /// Currently previewed file, a relative path, and its contents.
selected_file: Option<SharedString>, selected_file: Option<SharedString>,
files: HashMap<String, FileContent>, files: HashMap<String, FileContent>,
/// Paths of cached previews, oldest first; feeds the eviction caps in /// Paths of cached previews, oldest first.
/// [`Self::evict_previews`]. /// Feeds the eviction caps in [`Self::evict_previews`].
file_order: VecDeque<String>, file_order: VecDeque<String>,
/// Total text bytes held by [`Self::files`]. /// Total text bytes held by [`Self::files`].
preview_bytes: usize, preview_bytes: usize,
/// Reads in flight, to avoid duplicate loads. /// Reads in flight, to avoid duplicate loads.
loading_files: HashSet<String>, loading_files: HashSet<String>,
/// Latest commit touching a previewed file (or the README), keyed by path. /// Latest commit touching a previewed file or the README, keyed by path.
commits: HashMap<String, FileCommit>, commits: HashMap<String, FileCommit>,
/// Paths queued for the next batched commit query (see [`Self::load_commits`]). /// Paths queued for the next batched commit query, see [`Self::load_commits`].
pending_commits: Vec<String>, pending_commits: Vec<String>,
/// A batched commit query is in flight. /// A batched commit query is in flight.
loading_commits: bool, loading_commits: bool,
/// Active header tab: 0 = Files (tree), 1 = Commits. /// Active header tab, 0 = Files tree, 1 = Commits.
active_tab: usize, active_tab: usize,
/// Commits reachable from HEAD, newest first; `None` until the walk /// Commits reachable from HEAD, newest first.
/// finishes (or fails). `commits` may be capped by /// `None` until the walk finishes or fails.
/// [`CommitList`]; `total` feeds the tab badge. /// [`CommitList`] caps the list, `total` feeds the tab badge.
all_commits: Option<CommitList>, all_commits: Option<CommitList>,
/// Commit walk in flight. /// Commit walk in flight.
loading_all_commits: bool, loading_all_commits: bool,
@@ -183,52 +185,51 @@ pub struct RepoDetailView {
error: Option<SharedString>, error: Option<SharedString>,
/// Commit HEAD currently points to, shown in the header button. /// Commit HEAD currently points to, shown in the header button.
head_commit: Option<FileCommit>, head_commit: Option<FileCommit>,
/// Branch selector (header): local branches, searchable. /// Branch selector in the header, local branches, searchable.
branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>, branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// Tag selector (header): tags, searchable. /// Tag selector in the header, tags, searchable.
tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>, tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// A branch/tag switch is in flight (checkout plus explorer reload). /// A branch/tag switch is in flight, checkout plus explorer reload.
switching_ref: bool, switching_ref: bool,
/// Bumped on every branch/tag switch; in-flight loads tagged with an /// Bumped on every branch/tag switch.
/// older generation are discarded when they complete. /// In-flight loads with an older generation are discarded when they complete.
ref_generation: u64, ref_generation: u64,
/// Derived NIP-34 header data (share targets, clone commands), /// Derived NIP-34 header data, share targets and clone commands.
/// rebuilt only when the announcement or the owner's NIP-05 changes /// Rebuilt only when the announcement or the owner's NIP-05 changes.
/// instead of on every render. /// Not on every render.
header_cache: Option<HeaderCache>, header_cache: Option<HeaderCache>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec /// In-flight tasks, finished tasks are pruned on every push.
/// stays bounded by the number of concurrent loads. /// The vec stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events alive. /// Subscriptions keeping the selectors' confirm events alive.
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
/// Observes the checkouts store, whose statuses feed the "ready to /// Observes the checkouts store.
/// contribute" banner of the repository panel. /// Its statuses feed the ready-to-contribute banner of the repository panel.
_checkouts_subscription: Subscription, _checkouts_subscription: Subscription,
/// `(path, branch)` ready-suggestions dismissed by the user, per panel. /// `(path, branch)` ready-suggestions dismissed by the user, per panel.
banner_dismissed: HashSet<(PathBuf, String)>, banner_dismissed: HashSet<(PathBuf, String)>,
/// The announced HEAD the ready-statuses were last requested with, and /// The announced HEAD the ready statuses were last requested with.
/// whether they were requested at all (re-requested only when the HEAD /// Whether they were requested at all.
/// the base default changes, e.g. when the store's first refresh /// Re-requested only when the HEAD, the base default, changes.
/// lands). /// E.g. when the store's first refresh lands.
ready_requested: bool, ready_requested: bool,
ready_head: Option<String>, ready_head: Option<String>,
/// Upstream repository (from this fork's `u` tag) the user asked to /// Upstream repository, from this fork's `u` tag, the user asked to open.
/// open, while its announcement is still being fetched. /// Its announcement is still being fetched.
pending_upstream: Option<RepoAddr>, pending_upstream: Option<RepoAddr>,
} }
impl RepoDetailView { impl RepoDetailView {
/// Open a repository announced on NIP-34: the store connects to the /// Open a repository announced on NIP-34.
/// announcement's relays and loads issues, PRs and statuses. /// The store connects to the announcement's relays and loads issues, PRs and statuses.
pub fn new( pub fn new(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
initial: Announcement, initial: Announcement,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
// The announcement we opened from already carries the repository's // The announcement we opened from already carries the NIP-34 `relays` tag.
// NIP-34 `relays` tag, so the store can connect to those relays // The store connects to those relays immediately, no bootstrap fetch wait.
// immediately instead of waiting for the bootstrap fetch.
let addr = initial.addr(); let addr = initial.addr();
let relays = initial.relays.clone(); let relays = initial.relays.clone();
let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
@@ -245,10 +246,9 @@ impl RepoDetailView {
view view
} }
/// Open a local repository discovered by the scan. There is no /// Open a local repository discovered by the scan.
/// announcement and no nostr store until the user initializes /// There is no announcement and no nostr store until the user publishes it to NIP-34.
/// (publishes) it to NIP-34, so the header shows an Init button /// The header shows an Init button instead of the NIP-34 actions.
/// instead of the NIP-34 actions.
pub fn new_local( pub fn new_local(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
local_path: PathBuf, local_path: PathBuf,
@@ -258,8 +258,8 @@ impl RepoDetailView {
Self::new_common(dock_area, None, None, Some(local_path), window, cx) Self::new_common(dock_area, None, None, Some(local_path), window, cx)
} }
/// Shared construction: file explorer state, ref selectors and the /// Shared construction.
/// deferred repository load. /// File explorer state, ref selectors and the deferred repository load.
fn new_common( fn new_common(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
initial: Option<Announcement>, initial: Option<Announcement>,
@@ -270,7 +270,7 @@ impl RepoDetailView {
) -> Self { ) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx)); let tree_state = cx.new(|cx| TreeState::new(cx));
// Empty until the clone completes; populated with the local refs. // Empty until the clone completes, then filled with the local refs.
let branch_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| { let branch_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
ComboboxState::new( ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()), SearchableVec::new(Vec::<SharedString>::new()),
@@ -292,9 +292,9 @@ impl RepoDetailView {
let subscriptions = vec![ let subscriptions = vec![
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
// `Change` fires only when the selection actually changed // `Change` fires only when the selection actually changed.
// (picking the already-selected branch emits nothing), so a // Picking the already-selected branch emits nothing.
// confirmed value always means a switch. // A confirmed value always means a switch.
if let ComboboxEvent::Change(values) = event if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first() && let Some(name) = values.first()
{ {
@@ -360,18 +360,18 @@ impl RepoDetailView {
} }
} }
/// Load the repository and populate the file explorer. A local /// Load the repository and populate the file explorer.
/// (not yet published) repository is opened straight from disk. An /// A local, not yet published, repository opens straight from disk.
/// announced repository's local clone (if any) is loaded first without /// An announced repository's clone, if any, loads first without touching the network.
/// touching the network, so an unreachable server can't block the /// An unreachable server can't block the panel.
/// panel; a background fetch then refreshes the refs and commit list. /// A background fetch then refreshes the refs and commit list.
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
cx.notify(); cx.notify();
// Local repositories live on disk at their scan path; there is no // Local repositories live on disk at their scan path.
// clone to ensure and no network refresh. // No clone step or network refresh applies here.
if let Some(local_path) = self.local_path.clone() { if let Some(local_path) = self.local_path.clone() {
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
let data = cx let data = cx
@@ -401,8 +401,8 @@ impl RepoDetailView {
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let addr = initial.addr(); let addr = initial.addr();
let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect(); let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect();
// Captured before the loads start: a branch/tag switch bumps it, and // Captured before the loads start.
// the refresh below is discarded when that happens. // A branch/tag switch bumps the generation, discarding the refresh below.
let refresh_generation = self.ref_generation; let refresh_generation = self.ref_generation;
let disk = { let disk = {
@@ -420,7 +420,7 @@ impl RepoDetailView {
let disk = disk.await; let disk = disk.await;
let had_clone = matches!(&disk, Ok(Some(_))); let had_clone = matches!(&disk, Ok(Some(_)));
// No local clone yet: clone from the network (blocking), then load. // No local clone yet, so clone from the network then load.
let data = match disk { let data = match disk {
Ok(Some(data)) => Ok(data), Ok(Some(data)) => Ok(data),
Ok(None) => { Ok(None) => {
@@ -445,9 +445,9 @@ impl RepoDetailView {
cx.notify(); cx.notify();
})?; })?;
// Refresh the clone from the network in the background; when it // Refresh the clone from the network in the background.
// completes, update the refs and commit list. Loads started // When it completes, update the refs and commit list.
// before a branch/tag switch are discarded via the generation. // Loads started before a branch/tag switch are discarded via the generation.
if !had_clone { if !had_clone {
return Ok(()); return Ok(());
} }
@@ -458,16 +458,15 @@ impl RepoDetailView {
let Some(repo) = cache.open(&addr)? else { let Some(repo) = cache.open(&addr)? else {
return Ok::<_, Error>(None); return Ok::<_, Error>(None);
}; };
// Best-effort: a failed fetch (e.g. offline) keeps the // Best-effort, a fetch failure, e.g. offline, keeps the cached state.
// cached state, which is already shown. // The state is already shown.
signed_git::fetch_all(&repo).ok(); signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf); let worktree = repo.workdir().map(Path::to_path_buf);
// A fetch never moves a mirror's local branches, so a // A fetch never moves a mirror's local branches.
// push landing on the grasp servers (own repo pushed // A push landing on the grasp servers would never show up.
// from a checkout, or an update fetched here) would // That covers own repo pushes from a checkout and updates fetched here.
// never show up. Fast-forward them from the remote, // Fast-forward branches from the remote, like `git pull --ff-only`.
// like `git pull --ff-only` on every branch; only the // Only the checked-out branch's worktree can change on disk.
// checked-out branch's worktree can change on disk.
let moved = match &worktree { let moved = match &worktree {
Some(worktree) => { Some(worktree) => {
signed_git::fast_forward_branches(worktree).unwrap_or(false) signed_git::fast_forward_branches(worktree).unwrap_or(false)
@@ -494,10 +493,9 @@ impl RepoDetailView {
} }
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
if moved { if moved {
// The mirror caught up with the remote (e.g. the // The mirror caught up with the remote.
// push of an owned checkout just landed): rebuild // E.g. the push of an owned checkout just landed.
// the explorer, previews and commit list from the // Rebuild the explorer, previews and commit list from the worktree.
// updated worktree.
this.reload_worktree(cx); this.reload_worktree(cx);
cx.notify(); cx.notify();
return; return;
@@ -539,8 +537,9 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Apply the loaded repository data: explorer tree, README preview, /// Apply the loaded repository data.
/// ref selectors and HEAD commit, then start the commit-list walk. /// Sets the explorer tree, README preview, ref selectors and HEAD commit.
/// Then starts the commit-list walk.
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) { fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
let RepoData { let RepoData {
tree, tree,
@@ -563,8 +562,8 @@ impl RepoDetailView {
state.set_items(tree_items(tree, false), cx); state.set_items(tree_items(tree, false), cx);
}); });
// Populate the branch/tag selectors with the local refs, // Populate the branch/tag selectors with the local refs.
// selecting the branch HEAD points to. // Select the branch HEAD points to.
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect(); let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect(); let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
@@ -591,8 +590,8 @@ impl RepoDetailView {
} }
} }
/// Clone the repository into a folder chosen by the user (outside the cache), /// Clone the repository into a user-chosen folder outside the cache.
/// then open the new clone in the system file manager. /// Then open the new clone in the system file manager.
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.cloning { if self.cloning {
return; return;
@@ -605,8 +604,8 @@ impl RepoDetailView {
let addr = announcement.addr(); let addr = announcement.addr();
let clone_urls: Vec<String> = let clone_urls: Vec<String> =
announcement.clone.iter().map(ToString::to_string).collect(); announcement.clone.iter().map(ToString::to_string).collect();
// Directory name: the display name, falling back to the repo id; // Directory name, the display name falling back to the repo id.
// both sanitized to a safe single path component. // Both are sanitized to a safe single path component.
let name = announcement let name = announcement
.name .name
.as_ref() .as_ref()
@@ -633,8 +632,8 @@ impl RepoDetailView {
}); });
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
// `Ok(Ok(Some(paths)))` means the user picked a folder; a // `Ok(Ok(Some(paths)))` means the user picked a folder.
// cancel (or a picker failure) resolves to anything else. // A cancel or picker failure resolves to anything else.
let picked = match prompt.await { let picked = match prompt.await {
Ok(Ok(Some(mut paths))) => paths.pop(), Ok(Ok(Some(mut paths))) => paths.pop(),
_ => None, _ => None,
@@ -659,8 +658,8 @@ impl RepoDetailView {
match result { match result {
Ok(_) => { Ok(_) => {
cx.open_with_system(&destination_for_open); cx.open_with_system(&destination_for_open);
// Remember the clone as a checkout of this // Remember the clone as a checkout of this repository.
// repository, so the New PR panel pre-fills it. // The New PR panel pre-fills it.
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| { checkouts.update(cx, |store, cx| {
store.record(destination, addr, cx); store.record(destination, addr, cx);
@@ -679,15 +678,14 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Preview the file at `path` (relative to the worktree root). /// Preview the file at `path`, relative to the worktree root.
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) { fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
if self.files.contains_key(path) { if self.files.contains_key(path) {
// The file is cached, but the persistent markdown/code state may // The file is cached, but the markdown or code state may hold a different file.
// still hold a different file; re-point it at this one (the parse // Re-point it at this one, the parse runs on a background task either way.
// runs on a background task either way). Without this, the pane // Without this, the pane would show a spinner forever.
// would show a spinner forever.
if let Some(FileContent::Text(text)) = self.files.get(path) { if let Some(FileContent::Text(text)) = self.files.get(path) {
let text = text.clone(); let text = text.clone();
if is_markdown_path(path) { if is_markdown_path(path) {
@@ -706,8 +704,8 @@ impl RepoDetailView {
return; return;
} }
// Paths come from our own tree walk, but never trust them: refuse // Paths come from our own tree walk, but never trust them.
// anything that could escape the worktree. // Refuse anything that could escape the worktree.
let rel = Path::new(path); let rel = Path::new(path);
let unsafe_path = rel.is_absolute() let unsafe_path = rel.is_absolute()
|| rel.components().any(|c| { || rel.components().any(|c| {
@@ -735,9 +733,9 @@ impl RepoDetailView {
let content = cx let content = cx
.background_spawn(async move { .background_spawn(async move {
let full = worktree.join(&path_for_read); let full = worktree.join(&path_for_read);
// Refuse oversized files before reading them: reading a // Refuse oversized files before reading them.
// multi-gigabyte file just to classify it as too large // Reading a multi-gigabyte file just to classify it is wasteful.
// would waste the disk and memory bandwidth. // It would burn disk and memory bandwidth.
let metadata = match std::fs::metadata(&full) { let metadata = match std::fs::metadata(&full) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => return Err(anyhow::anyhow!("{}", error)), Err(error) => return Err(anyhow::anyhow!("{}", error)),
@@ -757,10 +755,10 @@ impl RepoDetailView {
.await; .await;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
// The worktree was switched while this file was reading; // The worktree was switched while this file was reading.
// the result belongs to the previous branch. Clear the // The result belongs to the previous branch.
// in-flight marker either way, or the path could never be // Clear the in-flight marker either way.
// loaded again. // Otherwise the path could never be loaded again.
if generation != this.ref_generation { if generation != this.ref_generation {
this.loading_files.remove(&path); this.loading_files.remove(&path);
return; return;
@@ -802,8 +800,8 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Queue `path` for the per-file commit query; requests are batched into /// Queue `path` for the per-file commit query.
/// one history walk (see [`Self::load_commits`]). /// Requests are batched into one history walk, see [`Self::load_commits`].
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) { fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return; return;
@@ -814,10 +812,10 @@ impl RepoDetailView {
} }
} }
/// Walk history once for every queued path on a background task, and /// Walk history once for every queued path on a background task.
/// cache the latest commit touching each of them in [`Self::commits`] /// Cache the latest commit touching each path in [`Self::commits`].
/// (for the file header in the content column). Batching shares one /// That feeds the file header in the content column.
/// walk across all paths queued while the previous walk was in flight. /// Batching shares one walk across paths queued while the previous walk ran.
fn load_commits(&mut self, cx: &mut Context<Self>) { fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits { if self.pending_commits.is_empty() || self.loading_commits {
return; return;
@@ -849,10 +847,9 @@ impl RepoDetailView {
.insert(path.to_string_lossy().into_owned(), commit); .insert(path.to_string_lossy().into_owned(), commit);
} }
} }
// Paths queued while the walk was in flight start the next // Paths queued while the walk was in flight start the next batch.
// batch. A stale walk (branch switched mid-flight) must not // A stale walk, branch switched mid-flight, must not strand them.
// strand them, so this runs under the current generation // This runs under the current generation regardless of the result.
// regardless of whether the result was applied.
if !this.pending_commits.is_empty() { if !this.pending_commits.is_empty() {
this.load_commits(cx); this.load_commits(cx);
} }
@@ -865,9 +862,9 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Walk all commits reachable from HEAD on a background task, for the /// Walk all commits reachable from HEAD on a background task.
/// Commits tab and its total-count badge. The list is capped by /// For the Commits tab and its total-count badge.
/// [`CommitList`]; only the newest commits are materialized. /// [`CommitList`] caps the list, only the newest commits are materialized.
fn load_all_commits(&mut self, cx: &mut Context<Self>) { fn load_all_commits(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() { if self.loading_all_commits || self.all_commits.is_some() {
return; return;
@@ -886,8 +883,8 @@ impl RepoDetailView {
.await; .await;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// A stale walk (branch switched mid-flight) must not leave // A stale walk, branch switched mid-flight, must not leave the flag set.
// the flag set, or the Commits tab would spin forever. // Otherwise the Commits tab would spin forever.
if generation != this.ref_generation { if generation != this.ref_generation {
this.loading_all_commits = false; this.loading_all_commits = false;
return; return;
@@ -907,9 +904,9 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Open a new panel showing the diff of `commit_id` (all files it /// Open a new panel showing the diff of `commit_id`.
/// changed, with the line diff of each). Called from the Commits tab /// All files it changed, with the line diff of each.
/// rows and the latest-commit button in the header. /// Called from the Commits tab rows and the latest-commit button.
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) { fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else { let Some(worktree) = self.worktree.clone() else {
return; return;
@@ -930,9 +927,9 @@ impl RepoDetailView {
}); });
} }
/// Re-push the repository's refs to its announced grasp servers; the /// Re-push the repository's refs to its announced grasp servers.
/// menu trigger shows a spinner while the push is in flight, failures /// The menu trigger shows a spinner while the push is in flight.
/// appear in the panel's error banner. /// Failures appear in the panel's error banner.
fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pushing { if self.pushing {
return; return;
@@ -960,10 +957,10 @@ impl RepoDetailView {
})); }));
} }
/// Push the unpushed commits of the local checkout at /// Push the unpushed commits of the local checkout at `path`.
/// `path` (an owned repository's working copy) to the announced grasp servers, /// The checkout is an owned repository's working copy.
/// failures appear in the panel's error banner, /// Failures appear in the panel's error banner.
/// and on success the push statuses are recomputed so the banner clears. /// On success the push statuses are recomputed so the banner clears.
fn push_unpushed_checkout( fn push_unpushed_checkout(
&mut self, &mut self,
path: PathBuf, path: PathBuf,
@@ -978,8 +975,8 @@ impl RepoDetailView {
return; return;
}; };
// Keep the repository's announced default branch as the state // The state event's `HEAD` stays the announced default branch.
// event's `HEAD` when the checkout is on a side branch. // The checkout may be on a side branch.
let head = self let head = self
.store .store
.as_ref() .as_ref()
@@ -1003,10 +1000,10 @@ impl RepoDetailView {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
match result { match result {
Ok(()) => { Ok(()) => {
// The remote moved; recompute the push statuses so // The remote moved, so recompute the push statuses.
// the banner disappears, and refresh the mirror so // The banner disappears.
// the pushed commits appear in the panel right away // Refresh the mirror, fetch, fast-forward and an explorer reload.
// (fetch + fast-forward + explorer reload). // The pushed commits then show in the panel.
checkout.update(cx, |store, cx| { checkout.update(cx, |store, cx| {
store.request_push_statuses(&addr, cx); store.request_push_statuses(&addr, cx);
}); });
@@ -1024,9 +1021,9 @@ impl RepoDetailView {
})); }));
} }
/// Delete the repository from nostr (announcement, state and activity); /// Delete the repository from nostr, announcement, state and activity.
/// only offered to the repository owner. The sidebar list updates when /// Only offered to the repository owner.
/// the deletion events arrive. /// The sidebar list updates when the deletion events arrive.
fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(announcement) = self.announcement(cx).cloned() else { let Some(announcement) = self.announcement(cx).cloned() else {
return; return;
@@ -1048,7 +1045,7 @@ impl RepoDetailView {
})); }));
} }
/// Open the issues panel at the bottom of the dock area. /// Open the issues list panel in the dock area.
fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else { let Some(store) = self.store.clone() else {
return; return;
@@ -1064,7 +1061,7 @@ impl RepoDetailView {
}); });
} }
/// Open the pull requests panel at the bottom of the dock area. /// Open the pull requests list panel in the dock area.
fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else { let Some(store) = self.store.clone() else {
return; return;
@@ -1080,9 +1077,9 @@ impl RepoDetailView {
}); });
} }
/// Open the upstream repository (the `u` tag of this fork's announcement). /// Open the upstream repository, the `u` tag of this fork's announcement.
/// When the upstream announcement is not in the local database yet, /// The upstream announcement may not be in the local database yet.
/// subscribe for it and open the panel as soon as it lands. /// Subscribe for it and open the panel as soon as it lands.
fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_upstream.is_some() { if self.pending_upstream.is_some() {
return; return;
@@ -1151,8 +1148,8 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Check out `name` (a branch or tag picked in the header) and refresh /// Check out `name`, a branch or tag picked in the header.
/// the explorer once the switch completes. /// Refresh the explorer once the switch completes.
fn switch_ref( fn switch_ref(
&mut self, &mut self,
kind: RefKind, kind: RefKind,
@@ -1167,9 +1164,9 @@ impl RepoDetailView {
return; return;
}; };
// Branches and tags are mutually exclusive states of HEAD: selecting // Branches and tags are mutually exclusive states of HEAD.
// one clears the other selector. Remember the previous selections so // Selecting one clears the other selector.
// they can be restored if the checkout fails. // Remember the previous selections to restore them if the checkout fails.
let previous_branch = self.branch_select.read(cx).selected_value(); let previous_branch = self.branch_select.read(cx).selected_value();
let previous_tag = self.tag_select.read(cx).selected_value(); let previous_tag = self.tag_select.read(cx).selected_value();
@@ -1184,8 +1181,7 @@ impl RepoDetailView {
} }
} }
self.switching_ref = true; self.switching_ref = true;
// In-flight loads of the previous branch are discarded when they // In-flight loads of the previous branch are discarded when they complete.
// complete.
self.ref_generation += 1; self.ref_generation += 1;
cx.notify(); cx.notify();
@@ -1223,7 +1219,7 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Restore a selector to `previous`, or clear it (after a failed switch). /// Restore a selector to `previous`, or clear it after a failed switch.
fn restore_selection( fn restore_selection(
&self, &self,
select: &Entity<ComboboxState<SearchableVec<SharedString>>>, select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
@@ -1237,9 +1233,10 @@ impl RepoDetailView {
}); });
} }
/// Trigger body for the branch/tag selectors: the kind icon, the /// Trigger body for the branch/tag selectors.
/// selection (or placeholder) and the caret. `Combobox` replaces its /// The kind icon, the selection or placeholder, and the caret.
/// default trigger entirely, the only way to show an icon inside it. /// `Combobox` replaces its default trigger entirely.
/// That is the only way to show an icon inside it.
fn render_ref_trigger( fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>, ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName, icon: CustomIconName,
@@ -1273,10 +1270,10 @@ impl RepoDetailView {
.into_any_element() .into_any_element()
} }
/// Refresh the file explorer, preview pane and commit list after a /// Refresh the file explorer, preview pane and commit list after a successful switch.
/// successful branch or tag switch. The selectors were already updated /// The selectors were already updated by [`Self::switch_ref`].
/// by [`Self::switch_ref`]; [`Self::switching_ref`] stays set until this /// [`Self::switching_ref`] stays set until this reload finishes.
/// reload finishes, so a second switch cannot interleave. /// A second switch cannot interleave.
fn reload_worktree(&mut self, cx: &mut Context<Self>) { fn reload_worktree(&mut self, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else { let Some(worktree) = self.worktree.clone() else {
return; return;
@@ -1297,9 +1294,9 @@ impl RepoDetailView {
match result { match result {
Ok((snapshot, tree)) => { Ok((snapshot, tree)) => {
this.head_commit = snapshot.head_commit; this.head_commit = snapshot.head_commit;
// Rebuild the tree from scratch: entries of the // Rebuild the tree from scratch.
// previous branch are gone, and with them the // Entries of the previous branch are gone.
// expansion state. // The expansion state goes with them.
this.tree_state.update(cx, |state, cx| { this.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx); state.set_items(tree_items(tree, false), cx);
}); });
@@ -1346,9 +1343,10 @@ impl RepoDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Drop the oldest previews beyond the cache caps, keeping the currently /// Drop the oldest previews beyond the cache caps.
/// selected file. The parsed editor state of an evicted file is dropped /// Keep the currently selected file.
/// along with its entry, so re-opening it re-parses on a background task. /// An evicted file's parsed editor state drops with its entry.
/// Re-opening it re-parses on a background task.
fn evict_previews(&mut self) { fn evict_previews(&mut self) {
while (self.files.len() > MAX_PREVIEWED_FILES while (self.files.len() > MAX_PREVIEWED_FILES
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
@@ -1376,7 +1374,7 @@ impl RepoDetailView {
} }
} }
/// The latest announcement from the store, or the open-time snapshot; /// The latest announcement from the store or the open-time snapshot.
/// `None` for local repositories that haven't been published yet. /// `None` for local repositories that haven't been published yet.
fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
let store = self.store.as_ref()?; let store = self.store.as_ref()?;
@@ -1387,8 +1385,8 @@ impl RepoDetailView {
.or(self.initial.as_ref()) .or(self.initial.as_ref())
} }
/// Display name: the announcement's name (or ID) for announced /// Display name, the announcement's name or ID for announced repositories.
/// repositories, the directory name for local ones. /// The directory name for local ones.
fn display_name(&self, cx: &App) -> SharedString { fn display_name(&self, cx: &App) -> SharedString {
if let Some(path) = &self.local_path { if let Some(path) = &self.local_path {
return SharedString::from( return SharedString::from(
@@ -1407,9 +1405,8 @@ impl RepoDetailView {
.unwrap_or_default() .unwrap_or_default()
} }
/// The NIP-34 header (actions, issues/PR counts) or, for a local /// The NIP-34 header, actions and issues/PR counts.
/// repository that hasn't been published yet, the local header with an /// Or the local header with an Init button for an unpublished repository.
/// Init button.
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.local_path.is_some() { if self.local_path.is_some() {
return self.render_local_header(cx); return self.render_local_header(cx);
@@ -1426,9 +1423,9 @@ impl RepoDetailView {
return div().into_any_element(); return div().into_any_element();
}; };
// The header derives bech32 share targets and clone command strings // The header derives bech32 share targets and clone commands.
// from the announcement; rebuild them only when the announcement or // Rebuild them only when the announcement or the owner's NIP-05 changes.
// the owner's NIP-05 changes, not on every render. // Not on every render.
let nip05 = ProfileStore::global(cx) let nip05 = ProfileStore::global(cx)
.read(cx) .read(cx)
.get(&source.owner) .get(&source.owner)
@@ -1815,9 +1812,8 @@ impl RepoDetailView {
.into_any_element() .into_any_element()
} }
/// Header for a local (not yet published) repository: the directory /// Header for a local, not yet published, repository.
/// name and path with an Init button instead of the NIP-34 actions /// The directory name and path with an Init button instead of the NIP-34 actions.
/// (issues, pull requests, share, info, clone).
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
let name = self.display_name(cx); let name = self.display_name(cx);
let path = self let path = self
@@ -1879,8 +1875,7 @@ impl RepoDetailView {
.into_any_element() .into_any_element()
} }
/// Open the dialog guiding the user through publishing the local /// Open the dialog guiding the user through publishing the local repository to NIP-34.
/// repository to NIP-34.
fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(local_path) = self.local_path.clone() else { let Some(local_path) = self.local_path.clone() else {
return; return;
@@ -1889,47 +1884,47 @@ impl RepoDetailView {
init_dialog::open(local_path, view, window, cx); init_dialog::open(local_path, view, window, cx);
} }
/// Switch the repository into its NIP-34 mode after a successful init: /// Switch the repository into its NIP-34 mode after a successful init.
/// create the nostr store for the announced repository and drop the /// Creates the nostr store for the announced repository.
/// local (scan) identity. The worktree is unchanged, so the file /// Drops the local scan identity.
/// explorer keeps its loaded content. /// The worktree is unchanged, so the explorer keeps its loaded content.
pub(crate) fn apply_announcement( pub(crate) fn apply_announcement(
&mut self, &mut self,
announcement: Announcement, announcement: Announcement,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
// The repository is no longer a bare local repo: drop it from the // The repository is no longer a bare local repo.
// scan results so it leaves the sidebar's local section immediately. // Drop it from the scan results so it leaves the sidebar's local section.
if let Some(path) = self.local_path.take() { if let Some(path) = self.local_path.take() {
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
} }
let store = let store =
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
// Re-render on store refreshes (issues, PRs, statuses) and keep the // Re-render on store refreshes, issues, PRs and statuses.
// "ready to contribute" statuses of this repository requested. // Keep the ready-to-contribute statuses of this repository requested.
self.attach_store(&store, cx); self.attach_store(&store, cx);
self.store = Some(store); self.store = Some(store);
self.initial = Some(announcement); self.initial = Some(announcement);
cx.notify(); cx.notify();
} }
/// Observe the repository's store (re-render on refreshes) and request /// Observe the repository's store, re-render on refreshes.
/// the "ready to contribute" statuses for it. /// Request the ready-to-contribute statuses for it.
fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) { fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
self._subscriptions self._subscriptions
.push(cx.observe(store, |this, _store, cx| { .push(cx.observe(store, |this, _store, cx| {
cx.notify(); cx.notify();
// The first refresh fills the announced HEAD, which defaults // The first refresh fills the announced HEAD.
// the banner's base branch; re-request when it changes. // It defaults the banner's base branch, re-request when it changes.
this.refresh_ready_statuses(cx); this.refresh_ready_statuses(cx);
})); }));
self.refresh_ready_statuses(cx); self.refresh_ready_statuses(cx);
} }
/// (Re)request the statuses of this repository when the announced /// Request the statuses of this repository again when the announced HEAD changes.
/// HEAD - the base the checkouts are compared against, changed since the last request. /// The HEAD is the base the checkouts are compared against.
/// Repositories the user owns are watched for unpushed commits, /// Owned repositories are watched for unpushed commits.
/// other repositories for ready-to-contribute checkouts. /// Other repositories for ready-to-contribute checkouts.
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) { fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(entity) = self.store.clone() else { let Some(entity) = self.store.clone() else {
return; return;
@@ -1954,8 +1949,8 @@ impl RepoDetailView {
.is_some_and(|user| entity.read(cx).is_author(&user)); .is_some_and(|user| entity.read(cx).is_author(&user));
checkout.update(cx, |store, cx| { checkout.update(cx, |store, cx| {
// The ready statuses also keep the fast poll running while the // The ready statuses keep the fast poll running while the panel is open.
// panel is open (the sidebar's push watch alone polls slower). // The sidebar's push watch alone polls slower.
store.request_statuses(&addr, head, cx); store.request_statuses(&addr, head, cx);
if owned { if owned {
@@ -1964,10 +1959,11 @@ impl RepoDetailView {
}); });
} }
/// The first checkout ready for a pull request on this repository, /// The first checkout ready for a pull request on this repository.
/// not covered by an open PR of the signed-in user and not dismissed in /// Not covered by an open PR of the signed-in user.
/// this panel. The repository's own checkouts are not suggested here: /// Not dismissed in this panel.
/// their work is pushed (see [`Self::push_suggestion`]). /// The repository's own checkouts are not suggested here.
/// Their work is pushed, see [`Self::push_suggestion`].
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> { fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.as_ref()?; let store = self.store.as_ref()?;
let addr = store.read(cx).addr().clone(); let addr = store.read(cx).addr().clone();
@@ -1998,8 +1994,8 @@ impl RepoDetailView {
None None
} }
/// The first checkout of this owned repository with unpushed commits, /// The first checkout of this owned repository with unpushed commits.
/// not dismissed in this panel. /// Not dismissed in this panel.
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> { fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let entity = self.store.as_ref()?; let entity = self.store.as_ref()?;
let user = Backend::global(cx).read(cx).current_user()?; let user = Backend::global(cx).read(cx).current_user()?;
@@ -2015,8 +2011,8 @@ impl RepoDetailView {
}) })
} }
/// The "ready to push" banner of an owned repository: a local checkout /// The ready-to-push banner of an owned repository.
/// has unpushed commits, with a Push action and a dismiss control. /// A local checkout has unpushed commits, with a Push action and a dismiss control.
fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> { fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?; let status = self.push_suggestion(cx)?;
let commits = if status.ahead == 1 { let commits = if status.ahead == 1 {
@@ -2067,9 +2063,9 @@ impl RepoDetailView {
) )
} }
/// The "ready to contribute" banner of the repository panel: message, /// The ready-to-contribute banner of the repository panel.
/// a Create action opening the prefilled New PR panel, and a dismiss /// A message, a Create action opening the prefilled New PR panel.
/// control. /// Plus a dismiss control.
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> { fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.ready_suggestion(cx)?; let status = self.ready_suggestion(cx)?;
let commits = if status.ahead == 1 { let commits = if status.ahead == 1 {
@@ -2119,8 +2115,8 @@ impl RepoDetailView {
) )
} }
/// The tab row shared by both header variants: Files/Commits tabs, the /// The tab row shared by both header variants.
/// HEAD commit button and the branch/tag selectors. /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors.
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let commits_count = self.all_commits.as_ref().map(|list| list.total); let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none(); let worktree_empty = self.switching_ref || self.worktree.is_none();
@@ -2379,8 +2375,8 @@ impl Render for RepoDetailView {
} }
} }
/// Read the worktree state of `repo` (no network): entries, README, refs /// Read the worktree state of `repo`, no network.
/// and HEAD commit. /// Entries, README, refs and HEAD commit.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> { fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?; let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries); let tree = build_tree_items(&entries);
@@ -2390,8 +2386,9 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
None => None, None => None,
}; };
let worktree = repo.workdir().map(Path::to_path_buf); let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI: a broken ref must not prevent the // Ref listing is auxiliary UI.
// explorer from loading, so failures degrade to empty selectors. // A broken ref must not prevent the explorer from loading.
// Failures degrade to empty selectors.
let (branches, tags, current_branch) = match &worktree { let (branches, tags, current_branch) = match &worktree {
Some(_) => ( Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(), signed_git::repo_branches(repo).unwrap_or_default(),
@@ -2414,10 +2411,10 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
}) })
} }
/// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a /// The `nostr://...` clone URL of an announcement, NIP-34.
/// NIP-05 identifier when known (npub otherwise), the first announced relay /// The owner as a NIP-05 identifier when known, npub otherwise.
/// as a hint, and the repository identifier. `nip05` is the owner's /// The first announced relay is a hint, plus the repository identifier.
/// NIP-05 identifier from the profile store, already blank-filtered. /// `nip05` is the owner's NIP-05 identifier from the profile store, already blank-filtered.
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner; let owner = announcement.owner;
let user = nip05 let user = nip05
@@ -2435,16 +2432,16 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
SharedString::from(url) SharedString::from(url)
} }
/// The "Forked from …" row of the detail header: a clickable link to the /// The forked-from row of the detail header.
/// upstream repository when the `u` tag references a NIP-34 repo, /// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo.
/// plain text when it only carries a git URL. /// Plain text when it only carries a git URL.
fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> { fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> {
let upstream = announcement.upstream.as_ref()?; let upstream = announcement.upstream.as_ref()?;
let (label, clickable) = match &upstream.addr { let (label, clickable) = match &upstream.addr {
Some(addr) => { Some(addr) => {
// Prefer the upstream's display name when its announcement // Prefer the upstream's display name when its announcement is known locally.
// is already known locally fall back to its repository id. // Fall back to its repository id otherwise.
let name = RepoListStore::global(cx) let name = RepoListStore::global(cx)
.read(cx) .read(cx)
.announcements .announcements
@@ -2481,9 +2478,10 @@ fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Op
}) })
} }
/// Open `announcement` as a repository panel in the dock's center, returning /// Open `announcement` as a repository panel in the dock's center.
/// the new detail view. Shared by the explore list, the sidebar and fork /// Returns the new detail view.
/// links so every entry point opens repositories identically. /// Shared by the explore list, the sidebar and fork links.
/// Every entry point opens repositories identically.
pub(crate) fn open_repo_panel( pub(crate) fn open_repo_panel(
dock_area: &WeakEntity<DockArea>, dock_area: &WeakEntity<DockArea>,
announcement: &Announcement, announcement: &Announcement,
@@ -1,9 +1,3 @@
//! The "new pull request" panel: pick a compare source — a local checkout
//! or an announced fork of the repository — a base and a compare branch
//! (GitHub-style), review the diff and the commit list, then publish the PR
//! with only a title and an optional description. The patch series is
//! generated at submit time; there is no patch input.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
@@ -41,47 +35,44 @@ use signed_ui::placeholder;
use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
use super::diff::{CommitDiffView, DiffPane}; use super::diff::{CommitDiffView, DiffPane};
/// The "new pull request" panel of a repository. /// The new pull request panel of a repository.
/// /// The compare side comes from a local checkout or an announced fork.
/// The compare side of the PR comes from one of two sources: /// A local checkout lists its own branches in both selectors.
/// /// Git ops and the tip push run in the checkout.
/// - **Local checkout**: both branch selectors list a user-picked local /// An announced fork imports its branches into the target's GitCache mirror.
/// checkout's branches; git ops and the tip push run in the checkout. /// The base selector lists the mirror's `refs/remotes/origin/*` branches.
/// - **Announced fork**: the fork's branches are fetched into the target /// All git ops run in the mirror.
/// repository's GitCache mirror under `refs/fork/<owner>/<id>/*`, the base /// The Files and Commits tabs are built from `merge-base..compare` of the chosen refs.
/// selector lists the mirror's `refs/remotes/origin/*` branches, and all /// The patch series published with the PR comes from the same range at submit time.
/// git ops run in the mirror.
///
/// The compare view (Files/Commits tabs) is built from `merge-base..compare`
/// of the chosen refs, and the patch series published with the PR is
/// generated from the same range at submit time.
pub struct NewPullRequestView { pub struct NewPullRequestView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Dock area the panel lives in; commit diffs are opened there. /// Dock area the panel lives in, commit diffs are opened there.
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
/// Store of the target repository (for the announced HEAD default). /// Store of the target repository, source of the announced HEAD default.
store: Entity<RepoStore>, store: Entity<RepoStore>,
/// Display name of the repository, for the panel title. /// Display name of the repository, for the panel title.
repo_name: SharedString, repo_name: SharedString,
/// The user's local checkout: where both branches live in checkout mode /// The user's local checkout.
/// and where the tip is pushed from. `None` until a folder is picked. /// Both branches live there in checkout mode and the tip is pushed from there.
/// `None` until a folder is picked.
repo_path: Option<PathBuf>, repo_path: Option<PathBuf>,
/// Branches of the checkout, backing both selectors in checkout mode. /// Branches of the checkout, backing both selectors in checkout mode.
branches: Vec<SharedString>, branches: Vec<SharedString>,
/// Fork-backed compare state; `Some` switches the panel into fork mode /// Fork-backed compare state.
/// (the checkout above is kept so the user can switch back). /// `Some` switches the panel into fork mode.
/// The checkout above is kept so the user can switch back.
fork: Option<ForkCompare>, fork: Option<ForkCompare>,
/// Selected base branch (the target of the PR), short name. /// Selected base branch, the PR target, stored as a short name.
base: SharedString, base: SharedString,
/// Selected compare branch (the source of the PR), short name. /// Selected compare branch, the PR source, stored as a short name.
compare: SharedString, compare: SharedString,
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>, base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>, compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// Title input (required). /// Title input, required.
subject: Entity<InputState>, subject: Entity<InputState>,
/// Description input (optional). /// Description input, optional.
description: Entity<TextareaState>, description: Entity<TextareaState>,
/// Merge base of the selected branches; `None` until the compare loads. /// Merge base of the selected branches, `None` until the compare loads.
merge_base: Option<String>, merge_base: Option<String>,
/// Commits in `merge_base..compare`, newest first. /// Commits in `merge_base..compare`, newest first.
commits: Option<Vec<signed_git::FileCommit>>, commits: Option<Vec<signed_git::FileCommit>>,
@@ -89,13 +80,13 @@ pub struct NewPullRequestView {
loading: bool, loading: bool,
/// Error of the last compare or submit attempt. /// Error of the last compare or submit attempt.
error: Option<SharedString>, error: Option<SharedString>,
/// A submit (patch generation + publish) is in flight. /// A submit, patch generation and publish, is in flight.
submitting: bool, submitting: bool,
/// Bumped on every branch switch; stale compare results are discarded. /// Bumped on every branch switch, stale compare results are discarded.
compare_generation: u64, compare_generation: u64,
/// Active tab: 0 = Files, 1 = Commits. /// Active tab, 0 = Files and 1 = Commits.
active_tab: usize, active_tab: usize,
/// The compare diff (Files tab). /// The compare diff, the Files tab body.
pane: Entity<DiffPane>, pane: Entity<DiffPane>,
/// Virtual list state of the Commits tab. /// Virtual list state of the Commits tab.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
@@ -104,13 +95,13 @@ pub struct NewPullRequestView {
tasks: Vec<Task<Result<(), anyhow::Error>>>, tasks: Vec<Task<Result<(), anyhow::Error>>>,
} }
/// A fork-backed compare: the fork's heads are imported into the target /// A fork-backed compare.
/// repository's GitCache mirror under `refs/fork/<namespace>/*`, and the /// The fork's heads are imported into the target mirror under `refs/fork/<namespace>/*`.
/// mirror's own `refs/remotes/origin/*` track the base branches. /// The mirror's own `refs/remotes/origin/*` refs track the base branches.
struct ForkCompare { struct ForkCompare {
/// Fork announcement the compare branch is imported from. /// Fork announcement the compare branch is imported from.
announcement: Announcement, announcement: Announcement,
/// Import namespace: `<owner-hex>/<sanitized-id>`. /// Import namespace of the form `<owner-hex>/<sanitized-id>`.
namespace: String, namespace: String,
/// Path of the target repository's GitCache mirror. /// Path of the target repository's GitCache mirror.
mirror_path: PathBuf, mirror_path: PathBuf,
@@ -137,11 +128,11 @@ fn fork_namespace(announcement: &Announcement) -> String {
) )
} }
/// The announced forks of `base` a New PR compare can be built from: /// The announced forks of `base` a New PR compare can be built from.
/// announcements related by `u` tag or shared EUC, excluding the base /// Related by `u` tag or shared EUC, excluding the base itself.
/// itself and announcements without `clone` URLs (unfetchable). Own forks /// Announcements without `clone` URLs are unfetchable and excluded.
/// (announced by `user`) come first; the input order (newest first, as /// Own forks, announced by `user`, come first.
/// `RepoListStore` keeps it) is preserved within each group. /// Newest first as `RepoListStore` keeps them, order is preserved within each group.
fn fork_candidates<'a>( fn fork_candidates<'a>(
announcements: &'a [Announcement], announcements: &'a [Announcement],
base: &RepoAddr, base: &RepoAddr,
@@ -162,8 +153,8 @@ fn fork_candidates<'a>(
own.into_iter().chain(others).collect() own.into_iter().chain(others).collect()
} }
/// The display name of an announcement: its human-readable name, falling /// The display name of an announcement.
/// back to the repository id. /// Its human-readable name, falling back to the repository id.
fn fork_display_name(announcement: &Announcement) -> SharedString { fn fork_display_name(announcement: &Announcement) -> SharedString {
announcement announcement
.name .name
@@ -171,7 +162,7 @@ fn fork_display_name(announcement: &Announcement) -> SharedString {
.unwrap_or_else(|| SharedString::from(announcement.id.clone())) .unwrap_or_else(|| SharedString::from(announcement.id.clone()))
} }
/// A short label of a fork's owner for the source picker (hex prefix). /// A short label of a fork's owner for the source picker, a hex prefix.
fn shorten_owner(owner: &PublicKey) -> String { fn shorten_owner(owner: &PublicKey) -> String {
let hex = owner.to_hex(); let hex = owner.to_hex();
hex.chars().take(10).collect() hex.chars().take(10).collect()
@@ -190,8 +181,8 @@ fn truncate_label(label: &str) -> SharedString {
SharedString::from(label) SharedString::from(label)
} }
/// The compare-source menu entry of one local checkout folder: applies the /// The compare-source menu entry of one local checkout folder.
/// folder directly (no picker). /// Applies the folder directly, no picker.
fn checkout_source_item( fn checkout_source_item(
view: WeakEntity<NewPullRequestView>, view: WeakEntity<NewPullRequestView>,
path: PathBuf, path: PathBuf,
@@ -237,8 +228,8 @@ fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuI
}) })
} }
/// The compare-source menu entry of one announced fork: /// The compare-source menu entry of one announced fork.
/// imports its branches into the target's mirror and switches the panel to fork mode. /// Imports its branches into the target's mirror and switches the panel to fork mode.
fn fork_source_item( fn fork_source_item(
view: WeakEntity<NewPullRequestView>, view: WeakEntity<NewPullRequestView>,
announcement: Announcement, announcement: Announcement,
@@ -264,7 +255,7 @@ fn fork_source_item(
}) })
} }
/// One row of the compare-source menu: icon, title and a muted subtitle. /// One row of the compare-source menu, icon, title and a muted subtitle.
fn source_row<T>(icon: impl Into<Icon>, title: T, subtitle: T, cx: &App) -> AnyElement fn source_row<T>(icon: impl Into<Icon>, title: T, subtitle: T, cx: &App) -> AnyElement
where where
T: Into<SharedString>, T: Into<SharedString>,
@@ -340,8 +331,7 @@ impl NewPullRequestView {
}); });
let subscriptions = vec![ let subscriptions = vec![
// Re-evaluate the Create button's enabled state as the title // Re-evaluate the Create button's enabled state as the title changes.
// changes.
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| { cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
cx.notify(); cx.notify();
}), }),
@@ -395,8 +385,7 @@ impl NewPullRequestView {
tasks: Vec::new(), tasks: Vec::new(),
}; };
// Prefill: when the store knows an associated checkout of this repository, // Prefill with the store's freshest associated checkout, no folder dialog.
// apply the freshest one right away (no folder dialog).
let addr = view.store.read(cx).addr().clone(); let addr = view.store.read(cx).addr().clone();
if let Some(path) = CheckoutsStore::global(cx) if let Some(path) = CheckoutsStore::global(cx)
.read(cx) .read(cx)
@@ -410,13 +399,13 @@ impl NewPullRequestView {
view view
} }
/// Whether a compare source (a checkout or a fork) is applied. /// Whether a compare source, a checkout or a fork, is applied.
fn has_source(&self) -> bool { fn has_source(&self) -> bool {
self.repo_path.is_some() || self.fork.is_some() self.repo_path.is_some() || self.fork.is_some()
} }
/// The path git ops run against: the target's mirror in fork mode, the /// The path git ops run against.
/// user's checkout otherwise. /// The target's mirror in fork mode, the user's checkout otherwise.
fn work_path(&self) -> Option<PathBuf> { fn work_path(&self) -> Option<PathBuf> {
match &self.fork { match &self.fork {
Some(fork) => Some(fork.mirror_path.clone()), Some(fork) => Some(fork.mirror_path.clone()),
@@ -424,9 +413,9 @@ impl NewPullRequestView {
} }
} }
/// The full ref the selected base branch resolves to: the mirror's /// The full ref the selected base branch resolves to.
/// remote-tracking ref in fork mode, the plain branch name in checkout /// The mirror's remote-tracking ref in fork mode.
/// mode (where git resolves it through `refs/heads`). /// The plain branch name in checkout mode, git resolves it through `refs/heads`.
fn base_ref(&self) -> String { fn base_ref(&self) -> String {
match &self.fork { match &self.fork {
Some(_) => ForkCompare::base_ref(&self.base), Some(_) => ForkCompare::base_ref(&self.base),
@@ -434,9 +423,9 @@ impl NewPullRequestView {
} }
} }
/// The full ref the selected compare branch resolves to: the imported /// The full ref the selected compare branch resolves to.
/// `refs/fork/<namespace>` ref in fork mode, the plain branch name in /// The imported `refs/fork/<namespace>` ref in fork mode.
/// checkout mode. /// The plain branch name in checkout mode.
fn compare_ref(&self) -> String { fn compare_ref(&self) -> String {
match &self.fork { match &self.fork {
Some(fork) => fork.compare_ref(&self.compare), Some(fork) => fork.compare_ref(&self.compare),
@@ -444,9 +433,10 @@ impl NewPullRequestView {
} }
} }
/// Prompt for a local checkout; on success populate the branch selectors /// Prompt for a local checkout.
/// (defaults: the announced HEAD branch for the base, the checkout's /// On success populate the branch selectors and load the compare.
/// current branch for the compare) and load the compare. /// Defaults are the announced HEAD branch for the base.
/// The checkout's current branch is the default for the compare.
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let prompt = cx.prompt_for_paths(PathPromptOptions { let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false, files: false,
@@ -456,8 +446,8 @@ impl NewPullRequestView {
}); });
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
// `Ok(Ok(Some(paths)))` means the user picked a folder; a // `Ok(Ok(Some(paths)))` means the user picked a folder.
// cancel (or a picker failure) resolves to anything else. // A cancel or picker failure resolves to anything else.
let picked = match prompt.await { let picked = match prompt.await {
Ok(Ok(Some(mut paths))) => paths.pop(), Ok(Ok(Some(mut paths))) => paths.pop(),
_ => None, _ => None,
@@ -475,8 +465,8 @@ impl NewPullRequestView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Apply `path` as the local checkout (no picker): read its branches /// Apply `path` as the local checkout, no picker.
/// and current branch off the UI thread, then apply. /// Branches and current branch are read off the UI thread, then applied.
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) { fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let path = path.to_string_lossy().to_string(); let path = path.to_string_lossy().to_string();
@@ -504,9 +494,10 @@ impl NewPullRequestView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Apply a picked checkout: fill the selectors and load the compare. /// Apply a picked checkout, filling the selectors and loading the compare.
/// Leaves fork mode; a fork applied earlier keeps its import in the /// Leaves fork mode.
/// mirror (harmless) but the panel switches back to the checkout. /// A fork applied earlier keeps its import in the mirror, harmless.
/// The panel switches back to the checkout.
fn apply_checkout( fn apply_checkout(
&mut self, &mut self,
path: String, path: String,
@@ -533,9 +524,9 @@ impl NewPullRequestView {
return; return;
} }
// Defaults: the announced HEAD branch when the checkout has it // Defaults, the announced HEAD branch when the checkout has it.
// (falling back to `main`, then the first branch); the checkout's // Falling back to `main`, then the first branch.
// current branch for the compare side. // The checkout's current branch is the compare side default.
let announced = self.store.read(cx).head.clone(); let announced = self.store.read(cx).head.clone();
let base = announced let base = announced
.as_ref() .as_ref()
@@ -551,8 +542,8 @@ impl NewPullRequestView {
self.error = None; self.error = None;
self.branches = branches.into_iter().map(SharedString::from).collect(); self.branches = branches.into_iter().map(SharedString::from).collect();
// Learning: remember this folder as a checkout of the target // Remember this folder as a checkout of the target repository.
// repository, so the next panel pre-fills it. // The next panel pre-fills it.
let addr = self.store.read(cx).addr().clone(); let addr = self.store.read(cx).addr().clone();
CheckoutsStore::global(cx).update(cx, |store, cx| { CheckoutsStore::global(cx).update(cx, |store, cx| {
store.record(PathBuf::from(&path), addr, cx); store.record(PathBuf::from(&path), addr, cx);
@@ -575,16 +566,16 @@ impl NewPullRequestView {
self.reload_compare(window, cx); self.reload_compare(window, cx);
} }
/// The base repository of the panel: its address and announced EUC, /// The base repository of the panel, its address and announced EUC.
/// used to find fork candidates. /// Used to find fork candidates.
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) { fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
let store = self.store.read(cx); let store = self.store.read(cx);
let euc = store.announcement.as_ref().and_then(|a| a.euc.clone()); let euc = store.announcement.as_ref().and_then(|a| a.euc.clone());
(store.addr().clone(), euc) (store.addr().clone(), euc)
} }
/// Announced forks of the target repository the compare can be built /// Announced forks of the target repository a compare can use, own first.
/// from (own forks first), re-read whenever the picker opens. /// Re-read whenever the picker opens.
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> { fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
let (base, euc) = self.base_repo(cx); let (base, euc) = self.base_repo(cx);
let user = Backend::global(cx).read(cx).current_user(); let user = Backend::global(cx).read(cx).current_user();
@@ -595,11 +586,12 @@ impl NewPullRequestView {
.collect() .collect()
} }
/// Compare against an announced fork: ensure the target's GitCache /// Compare against an announced fork.
/// mirror, import the fork's heads under `refs/fork/…`, then fill the /// The target's GitCache mirror is ensured, then the fork's heads land under `refs/fork/…`.
/// selectors (base from `refs/remotes/origin/*`, compare from the /// The base selector lists `refs/remotes/origin/*`, the compare the import.
/// import) and load the compare. Picking the fork already applied /// Then the compare loads.
/// refreshes it instead (re-import + reload), keeping the branch selection. /// Picking the fork already applied refreshes it, re-import and reload.
/// The branch selection is kept.
fn choose_fork( fn choose_fork(
&mut self, &mut self,
announcement: Announcement, announcement: Announcement,
@@ -625,13 +617,13 @@ impl NewPullRequestView {
.map(|a| a.clone.iter().map(ToString::to_string).collect()) .map(|a| a.clone.iter().map(ToString::to_string).collect())
.unwrap_or_default(); .unwrap_or_default();
// The default compare branch of the fork, if the refreshed fork is // Keep the current compare and base when the fork is already applied.
// the one applied and its branch still exists. // apply_fork drops them when the branch no longer exists.
let keep_compare = refresh.then(|| self.compare.clone()); let keep_compare = refresh.then(|| self.compare.clone());
let keep_base = refresh.then(|| self.base.clone()); let keep_base = refresh.then(|| self.base.clone());
// The fork applied when the fetch started; if the user switches the // The fork applied when the fetch started.
// source mid-flight, the result must not clobber the newer state. // A source switch mid-flight must not let the stale result clobber the newer state.
let expected_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); let expected_fork = self.fork.as_ref().map(|fork| fork.announcement.addr());
self.loading = true; self.loading = true;
@@ -639,9 +631,9 @@ impl NewPullRequestView {
cx.notify(); cx.notify();
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
// The fork and the base must share history for a merge-base to exist, // The fork and base must share history for a merge-base to exist.
// so the target's mirror is the object store both sides land in. // The target's mirror is the object store both sides land in.
// `ensure_clone` fetches `origin` when the mirror exists already. // `ensure_clone` fetches `origin` when the mirror already exists.
let result = cx let result = cx
.background_spawn({ .background_spawn({
let cache = cache.clone(); let cache = cache.clone();
@@ -651,13 +643,13 @@ impl NewPullRequestView {
let clone_urls = clone_urls.clone(); let clone_urls = clone_urls.clone();
let mirror_path = mirror_path.clone(); let mirror_path = mirror_path.clone();
async move { async move {
// The fork and the base must share history for a // The fork and base must share history for a merge-base to exist.
// merge-base to exist, so the target's mirror is the // The target's mirror is the object store both sides land in.
// object store both sides land in. `ensure_clone` // `ensure_clone` fetches `origin` when the mirror already exists.
// fetches `origin` when the mirror exists already.
cache.ensure_clone(&base, &base_clone_urls)?; cache.ensure_clone(&base, &base_clone_urls)?;
// Prune stale imports of any fork, then import this fork's heads under its namespace. // Prune stale imports of any fork.
// Then import this fork's heads under its namespace.
delete_refs_with_prefix(&mirror_path, "refs/fork")?; delete_refs_with_prefix(&mirror_path, "refs/fork")?;
fetch_repo_refs( fetch_repo_refs(
@@ -666,7 +658,7 @@ impl NewPullRequestView {
&format!("+refs/heads/*:refs/fork/{namespace}/*"), &format!("+refs/heads/*:refs/fork/{namespace}/*"),
)?; )?;
// Both branch lists are short names, kept sorted like the checkout's. // Both branch lists are short names, sorted like the checkout's.
let strip = |refs: Vec<String>, prefix: &str| { let strip = |refs: Vec<String>, prefix: &str| {
let mut names: Vec<String> = refs let mut names: Vec<String> = refs
.into_iter() .into_iter()
@@ -696,7 +688,8 @@ impl NewPullRequestView {
.await; .await;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
// A source switch mid-flight (e.g. the user picked a folder while the fork was fetching) discards the stale result. // A source switch mid-flight discards the stale result.
// E.g. the user picked a folder while the fork was fetching.
let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); let applied = this.fork.as_ref().map(|fork| fork.announcement.addr());
if applied != expected_fork { if applied != expected_fork {
this.loading = false; this.loading = false;
@@ -721,7 +714,7 @@ impl NewPullRequestView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Apply an imported fork: fill the selectors and load the compare. /// Apply an imported fork, filling the selectors and loading the compare.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn apply_fork( fn apply_fork(
&mut self, &mut self,
@@ -738,8 +731,8 @@ impl NewPullRequestView {
let (base_branches, compare_branches) = match result { let (base_branches, compare_branches) = match result {
Ok(branches) => branches, Ok(branches) => branches,
Err(error) => { Err(error) => {
// Keep the previous source (if any); the error is shown // Keep the previous source, if any.
// inline next to the compare bar. // The error shows inline next to the compare bar.
self.error = Some(format!("Could not compare against the fork: {error}").into()); self.error = Some(format!("Could not compare against the fork: {error}").into());
cx.notify(); cx.notify();
return; return;
@@ -764,11 +757,10 @@ impl NewPullRequestView {
.map(SharedString::from) .map(SharedString::from)
.collect(); .collect();
// Defaults: the announced HEAD branch when the mirror has it // Base defaults to the announced HEAD branch when the mirror has it.
// (falling back to `main`, then the first branch); the fork's // Otherwise `main`, then the first branch.
// `main` for the compare side (falling back to the first branch). // The fork's `main` is the compare default, else the first branch.
// A refresh keeps the previous selection when the branch still // A refresh keeps the previous selection when the branch still exists.
// exists.
let announced = self.store.read(cx).head.clone(); let announced = self.store.read(cx).head.clone();
let contains = let contains =
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name); |name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
@@ -817,18 +809,17 @@ impl NewPullRequestView {
self.reload_compare(window, cx); self.reload_compare(window, cx);
} }
/// (Re)compute `merge_base..compare` of the selected branches on a /// Recompute `merge_base..compare` of the selected branches on a background task.
/// background task: the merge base, the commit list and the diff. Runs /// Computes the merge base, the commit list and the diff.
/// against the work path (the checkout, or the mirror in fork mode) /// Runs against the work path, the checkout or the mirror in fork mode.
/// using the full refs of both branches, so base `main` and fork `main` /// Full refs keep base `main` and fork `main` distinct.
/// stay distinct.
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.work_path() else { let Some(repo_path) = self.work_path() else {
return; return;
}; };
let base = self.base_ref(); let base = self.base_ref();
let compare = self.compare_ref(); let compare = self.compare_ref();
// Short names for the error copy; the full refs go to git. // Short names for the error copy, the full refs go to git.
let base_name = self.base.to_string(); let base_name = self.base.to_string();
let compare_name = self.compare.to_string(); let compare_name = self.compare.to_string();
@@ -879,8 +870,8 @@ impl NewPullRequestView {
.await; .await;
this.update_in(cx, |this, _window, cx| { this.update_in(cx, |this, _window, cx| {
// A stale result (the branches changed mid-flight) must not // A stale result, branches changed mid-flight, must not clobber a newer compare.
// clobber a newer compare; the newer task clears the flag. // The newer task clears the flag.
if generation != this.compare_generation { if generation != this.compare_generation {
return; return;
} }
@@ -908,9 +899,10 @@ impl NewPullRequestView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Publish the pull request: generate the patch series from the checkout /// Publish the pull request.
/// on a background task, hand it to the store, and close the panel once /// Generate the patch series on a background task and hand it to the store.
/// the publish is underway (errors surface in the pull request list). /// Close the panel once the publish is underway.
/// Errors surface in the pull request list.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting || self.loading { if self.submitting || self.loading {
return; return;
@@ -930,8 +922,8 @@ impl NewPullRequestView {
// The published `branch-name` is the compare branch's short name. // The published `branch-name` is the compare branch's short name.
let branch_name = self.compare.to_string(); let branch_name = self.compare.to_string();
// The patch is generated from the compare ref: a plain branch name // The patch comes from the compare ref.
// in checkout mode, the imported `refs/fork/…` ref in fork mode. // Plain branch name in checkout mode, imported `refs/fork/…` ref in fork mode.
let compare_ref = self.compare_ref(); let compare_ref = self.compare_ref();
let store = self.store.clone(); let store = self.store.clone();
let dock_area = self.dock_area.clone(); let dock_area = self.dock_area.clone();
@@ -942,7 +934,8 @@ impl NewPullRequestView {
cx.notify(); cx.notify();
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
// Regenerate the series at submit time so the published patch covers the current tip of the compare branch. // Regenerate the series at submit time.
// The published patch covers the current tip of the compare branch.
let patch = cx let patch = cx
.background_spawn({ .background_spawn({
let repo_path = repo_path.clone(); let repo_path = repo_path.clone();
@@ -1009,7 +1002,7 @@ impl NewPullRequestView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Open the diff of `commit_id` (from the Commits tab) in a new panel. /// Open the diff of `commit_id`, from the Commits tab, in a new panel.
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) { fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.work_path() else { let Some(repo_path) = self.work_path() else {
return; return;
@@ -1033,8 +1026,8 @@ impl NewPullRequestView {
}); });
} }
/// The compare bar: base/compare selectors, the source picker (local /// The compare bar, base and compare selectors.
/// checkout / announced fork) and the Create button. /// Plus the source picker, local checkout or announced fork, and the Create button.
fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement { fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement {
let has_source = self.has_source(); let has_source = self.has_source();
let can_submit = has_source let can_submit = has_source
@@ -1047,8 +1040,8 @@ impl NewPullRequestView {
.is_some_and(|commits| !commits.is_empty()) .is_some_and(|commits| !commits.is_empty())
&& !self.subject.read(cx).value().is_empty(); && !self.subject.read(cx).value().is_empty();
// Source-picker data, snapshotted when the menu is built // Source-picker data snapshotted when the menu is built.
// (each open rebuilds the items from the live announcements). // Each open rebuilds the items from the live announcements.
let source_menu = self.source_menu(cx); let source_menu = self.source_menu(cx);
let source_label = self.source_trigger(); let source_label = self.source_trigger();
let source_tooltip = match &self.fork { let source_tooltip = match &self.fork {
@@ -1169,7 +1162,7 @@ impl NewPullRequestView {
.into_any_element() .into_any_element()
} }
/// The source picker's trigger: truncated label of the applied source. /// The source picker's trigger, a truncated label of the applied source.
fn source_trigger(&self) -> SharedString { fn source_trigger(&self) -> SharedString {
match &self.fork { match &self.fork {
Some(fork) => truncate_label(&fork_display_name(&fork.announcement)), Some(fork) => truncate_label(&fork_display_name(&fork.announcement)),
@@ -1180,18 +1173,18 @@ impl NewPullRequestView {
} }
} }
/// Build the compare-source menu: switching back to the local checkout, /// Build the compare-source menu.
/// then the announced forks of the target repository (own forks first). /// The local checkout entries first, then the announced forks, own forks first.
/// Picking the fork already applied re-fetches it. Rebuilt every time /// Picking the fork already applied re-fetches it.
/// the menu opens, so the candidates are always current. /// Rebuilt every time the menu opens, so the candidates stay current.
fn source_menu( fn source_menu(
&self, &self,
cx: &Context<Self>, cx: &Context<Self>,
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static { ) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
// Associated local checkouts of the target repository, freshest // Associated local checkouts of the target repository, freshest first.
// first; the applied one is checked. The picker prompt stays // The applied one is checked.
// available underneath for arbitrary folders. // The picker prompt stays available underneath for arbitrary folders.
let addr = self.store.read(cx).addr().clone(); let addr = self.store.read(cx).addr().clone();
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
let active_path = (self.fork.is_none()) let active_path = (self.fork.is_none())
@@ -1345,8 +1338,8 @@ impl NewPullRequestView {
} }
} }
/// The Commits tab: `merge_base..compare` in a virtual list; clicking a /// The Commits tab, `merge_base..compare` in a virtual list.
/// row opens the commit's diff in a new panel. /// Clicking a row opens the commit's diff in a new panel.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement { fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(commits) = self.commits.as_ref() else { let Some(commits) = self.commits.as_ref() else {
return placeholder("No commits", cx); return placeholder("No commits", cx);
@@ -1423,8 +1416,9 @@ fn count_badge(count: usize, cx: &App) -> impl IntoElement {
.child(SharedString::from(count.to_string())) .child(SharedString::from(count.to_string()))
} }
/// The trigger of a branch selector: icon + current selection (or /// The trigger of a branch selector.
/// placeholder) + caret. `Combobox` replaces its default trigger entirely. /// Shows the icon, the current selection or placeholder, and the caret.
/// `Combobox` replaces its default trigger entirely.
fn render_ref_trigger( fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>, ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName, icon: CustomIconName,
@@ -1458,7 +1452,7 @@ fn render_ref_trigger(
.into_any_element() .into_any_element()
} }
/// Open the "new pull request" panel in the center dock. /// Open the new pull request panel in the center dock.
pub(super) fn open_new_pull_panel( pub(super) fn open_new_pull_panel(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>, store: Entity<RepoStore>,
@@ -1570,8 +1564,8 @@ mod tests {
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
"upstream", "upstream",
); );
// Newest first, as RepoListStore keeps them: an unrelated repo, the // Newest first, as RepoListStore keeps them.
// user's own fork (shared EUC), someone else's fork (u tag). // Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag.
let all = vec![ let all = vec![
announcements( announcements(
2, 2,
@@ -41,60 +41,61 @@ use super::helpers::{
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// Height of one commit row in the commits tab's virtual list: a single /// Height of one commit row in the commits tab's virtual list.
/// text line plus the 1px bottom border. /// A single text line plus the 1px bottom border.
const PR_COMMIT_ROW_HEIGHT: f32 = 37.; const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
/// Detail panel of a single pull request. /// Detail panel of a single pull request.
pub struct PullRequestDetailView { pub struct PullRequestDetailView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Dock area new panels (commit diffs) are added to. /// Dock area where new panels, e.g. commit diffs, are added.
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
/// Repo store holding the PR, its status and comments. /// Repo store holding the PR, its status and comments.
store: Entity<RepoStore>, store: Entity<RepoStore>,
/// Event id of the root PR event (kind 1618; updates are revisions). /// Event id of the root PR event, kind 1618.
/// Updates are revisions.
pr_id: EventId, pr_id: EventId,
/// Input state of the "leave a comment" textarea. /// Input state of the comment textarea.
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
/// Display name of the repository, for panels opened from here. /// Display name of the repository, for panels opened from here.
repo_name: SharedString, repo_name: SharedString,
/// Local clone the PR's git changes come from; `None` while the diff is /// Local clone the PR's git changes come from.
/// parsed from the nostr patch set (no commit diff viewer then). /// `None` when the diff is parsed from the nostr patch set.
/// No commit diff viewer in that case.
worktree: Option<PathBuf>, worktree: Option<PathBuf>,
/// Root PR's content, shown as plain text. /// Root PR's content, shown as plain text.
description: SharedString, description: SharedString,
/// Tip commit of the PR: the latest update's `c` tag, else the root's. /// Tip commit of the PR, the latest update's `c` tag or the root's.
current_commit: Option<SharedString>, current_commit: Option<SharedString>,
/// Commits of the patch series, in patch order (oldest first). /// Commits of the patch series, in patch order, oldest first.
commits: Vec<FileCommit>, commits: Vec<FileCommit>,
/// Parsed file changes of the patch; `None` while loading or on failure. /// Parsed file changes of the patch, `None` while loading or on failure.
diff: Option<CommitDiff>, diff: Option<CommitDiff>,
/// The patch is being parsed on a background task. /// The patch is being parsed on a background task.
loading: bool, loading: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits. /// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
active_tab: usize, active_tab: usize,
/// Changed-files explorer state. /// Changed-files explorer state.
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column. /// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>, selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines). /// Rows of the selected file's diff, hunk headers and lines.
rows: Vec<DiffRow>, rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`]. /// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows. /// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
/// Per-row heights of the commits tab's virtual list, built when the /// Per-row heights of the commits tab's virtual list, built when the patch series loads.
/// patch series is loaded.
commit_item_sizes: Rc<Vec<Size<Pixels>>>, commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab. /// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle, commit_scroll_handle: VirtualListScrollHandle,
/// Comment bodies as shared strings, keyed by comment event ID, so /// Comment bodies as shared strings, keyed by comment event ID.
/// re-renders don't clone full contents again (events are immutable, /// Re-renders don't clone full contents again.
/// so the cache never needs invalidation). /// Events are immutable, so the cache never needs invalidation.
contents: HashMap<EventId, SharedString>, contents: HashMap<EventId, SharedString>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec /// In-flight tasks, finished tasks are pruned on every push.
/// stays bounded by the number of concurrent loads. /// The vec stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>, tasks: Vec<Task<Result<(), anyhow::Error>>>,
/// Subscriptions keeping the view live as the store refreshes. /// Subscriptions keeping the view live as the store refreshes.
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
@@ -125,7 +126,7 @@ impl PullRequestDetailView {
}) })
.unwrap_or_default(); .unwrap_or_default();
// Re-render when the store refreshes (new comments, status changes). // Re-render when the store refreshes, new comments or status changes.
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())]; let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
// Defer loading until the window is ready, like the commit diff view. // Defer loading until the window is ready, like the commit diff view.
@@ -161,12 +162,12 @@ impl PullRequestDetailView {
} }
} }
/// Snapshot the PR events from the store, then compute the file changes /// Snapshot the PR events from the store.
/// and commit list on a background task and populate the tree. /// File changes and the commit list are computed on a background task.
/// /// The tree is populated from the result.
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch /// The changes come from the PR's patch set, NIP-34 `e`-linked patch events, when present.
/// events) when present; otherwise from the git repository (`c`, /// Otherwise from the git repository, `c`, `clone` and `merge-base` tags.
/// `clone` and `merge-base` tags), diffing the `merge-base..tip` range. /// Diffing the `merge-base..tip` range.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -226,9 +227,8 @@ impl PullRequestDetailView {
}) })
.await; .await;
// PRs without patch events (e.g. published by ngit) carry their // PRs without patch events, e.g. published by ngit, carry their changes in git.
// changes in the git repository: fetch the clone and diff the // Fetch the clone and diff the `merge-base..tip` range.
// `merge-base..tip` range.
let use_nostr = match &nostr_diff { let use_nostr = match &nostr_diff {
Ok(diff) => has_patch_link || !diff.files.is_empty(), Ok(diff) => has_patch_link || !diff.files.is_empty(),
Err(_) => true, Err(_) => true,
@@ -252,8 +252,8 @@ impl PullRequestDetailView {
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
let base = match base { let base = match base {
Some(base) => base, Some(base) => base,
// No `merge-base` tag: use the merge base of the // No `merge-base` tag.
// tip with the default branch. // Use the merge base of the tip and the default branch.
None => { None => {
let head = repo let head = repo
.head_id() .head_id()
@@ -322,15 +322,14 @@ impl PullRequestDetailView {
self.tasks.push(task); self.tasks.push(task);
} }
/// Show the diff of the file at `path` (selected in the tree). /// Show the diff of the file at `path`, selected in the tree.
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) { fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
self.set_diff_rows(path); self.set_diff_rows(path);
cx.notify(); cx.notify();
} }
/// Rebuild the virtual list state for the file at `path` and scroll back /// Rebuild the virtual list state for the file at `path` and scroll back to the top.
/// to the top.
fn set_diff_rows(&mut self, path: &str) { fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return; return;
@@ -370,7 +369,7 @@ impl PullRequestDetailView {
}); });
} }
/// One row of the changed-files tree: icon + name, indented by depth. /// One row of the changed-files tree, icon and name, indented by depth.
fn render_tree_item( fn render_tree_item(
ix: usize, ix: usize,
entry: &TreeEntry, entry: &TreeEntry,
@@ -387,7 +386,7 @@ impl PullRequestDetailView {
}) })
} }
/// Left column: the changed-files tree. /// Left column showing the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement { fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone(); let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
@@ -417,7 +416,7 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Right column: header of the selected file plus its diff. /// Right column, header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement { fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
@@ -446,8 +445,9 @@ impl PullRequestDetailView {
self.render_file_diff(file, cx.entity(), cx) self.render_file_diff(file, cx.entity(), cx)
} }
/// The diff of one file: a header with status and stats, then the hunks /// The diff of one file, with a header showing status and stats.
/// in a virtual list (a large diff is never materialized per frame). /// The hunks render in a virtual list.
/// A large diff is never materialized per frame.
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement { fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status { let status_label = match file.status {
signed_git::DiffStatus::Added => "A", signed_git::DiffStatus::Added => "A",
@@ -564,7 +564,7 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Underline tab bar: Discussion, Files and Commits. /// Underline tab bar with the Discussion, Files and Commits tabs.
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement { fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let active = self.active_tab; let active = self.active_tab;
let files_count = self.diff.as_ref().map(|diff| diff.files.len()); let files_count = self.diff.as_ref().map(|diff| diff.files.len());
@@ -610,8 +610,8 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Discussion tab: author, description and comments like the issue /// Discussion tab, author, description and comments like the issue panel.
/// panel, with the comment form at the end and a sidebar on the right. /// The comment form sits at the end, a sidebar on the right.
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
@@ -694,7 +694,7 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Right sidebar: participants and labels, like the issue panel. /// Right sidebar with participants and labels, like the issue panel.
fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement { fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement {
let profile_store = ProfileStore::global(cx); let profile_store = ProfileStore::global(cx);
let store = self.store.read(cx); let store = self.store.read(cx);
@@ -708,7 +708,7 @@ impl PullRequestDetailView {
return div().into_any_element(); return div().into_any_element();
}; };
// Participants: the PR author plus everyone who commented. // Participants, the PR author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![root.pubkey]; let mut participants: Vec<PublicKey> = vec![root.pubkey];
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex); participants.sort_by_key(PublicKey::to_hex);
@@ -776,8 +776,8 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Files tab: the changed-files tree on the left, the diff of the /// Files tab, the changed-files tree on the left.
/// selected file on the right. /// The diff of the selected file on the right.
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement { fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
h_flex() h_flex()
.flex_1() .flex_1()
@@ -789,8 +789,8 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Full-height Commits tab: every commit of the patch series, or a /// Full-height Commits tab.
/// status message while loading / when there are none. /// Every commit of the patch series, or a status message while loading or empty.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement { fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
@@ -838,8 +838,8 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// One row of the commits tab: id, summary, author and time. Clicking a /// One row of the commits tab, id, summary, author and time.
/// row opens the commit's diff in the bottom dock. /// Clicking a row opens the commit's diff in the bottom dock.
fn render_commit_row( fn render_commit_row(
&self, &self,
ix: usize, ix: usize,
@@ -882,8 +882,8 @@ impl PullRequestDetailView {
.child(SharedString::from(meta)), .child(SharedString::from(meta)),
) )
}) })
// Commits parsed from the nostr patch set may not exist in any // Commits parsed from the nostr patch set may not exist in any local clone.
// local clone; only git-backed PRs open a diff viewer. // Only git-backed PRs open a diff viewer.
.when_some(self.worktree.clone(), |this, worktree| { .when_some(self.worktree.clone(), |this, worktree| {
this.on_click(cx.listener(move |this, _event, window, cx| { this.on_click(cx.listener(move |this, _event, window, cx| {
this.open_commit_diff(worktree.clone(), &id, window, cx); this.open_commit_diff(worktree.clone(), &id, window, cx);
@@ -892,8 +892,8 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// One comment card, same design as the issue panel: avatar, author, /// One comment card, same design as the issue panel.
/// "commented" and age on the header row, content below. /// Header row holds the avatar, author, commented and age, content below.
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement { fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).collect(); let comments: Vec<&Event> = store.comments_of(id).collect();
@@ -907,8 +907,7 @@ impl PullRequestDetailView {
let author = profile.name(); let author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per // Comment bodies become shared strings once per comment, not per render.
// comment, not on every render.
let content = self let content = self
.contents .contents
.entry(comment.id) .entry(comment.id)
@@ -1002,7 +1001,7 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Always-visible header: status badge and title, like the issue panel. /// Always-visible header with a status badge and title, like the issue panel.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let current_commit = self.current_commit.clone(); let current_commit = self.current_commit.clone();
let (title, status, branch, author) = { let (title, status, branch, author) = {
@@ -1022,7 +1021,7 @@ impl PullRequestDetailView {
) )
}; };
// Only the PR author may publish revisions (kind 1619, NIP-34). // Only the PR author may publish revisions, NIP-34 kind 1619.
let backend = Backend::global(cx); let backend = Backend::global(cx);
let can_update = backend.read(cx).current_user() == Some(author); let can_update = backend.read(cx).current_user() == Some(author);
@@ -1104,8 +1103,9 @@ impl PullRequestDetailView {
} }
} }
/// Open the "update pull request" dialog: a patch input that submits a new /// Open the update pull request dialog.
/// revision through [`RepoStore::update_pull_request`] when confirmed. /// The patch input supplies the new revision.
/// Confirming calls [`RepoStore::update_pull_request`].
fn open_update_pull_request_dialog( fn open_update_pull_request_dialog(
store: Entity<RepoStore>, store: Entity<RepoStore>,
root: Event, root: Event,
@@ -1115,8 +1115,8 @@ fn open_update_pull_request_dialog(
let patch = cx.new(|cx| { let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...") TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
}); });
// Both the dialog body and the submit button capture the root event; // Both the dialog body and submit button capture the root event.
// share it instead of cloning into each closure. // Share it instead of cloning into each closure.
let root = Rc::new(root); let root = Rc::new(root);
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
@@ -1176,7 +1176,7 @@ fn sidebar_title(text: &str, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
/// The `c` tag of a PR event (tip of the proposed branch), as hex. /// The `c` tag of a PR event, the tip of the proposed branch, as hex.
fn current_commit_of(event: &Event) -> Option<String> { fn current_commit_of(event: &Event) -> Option<String> {
event event
.tags .tags
@@ -1187,8 +1187,8 @@ fn current_commit_of(event: &Event) -> Option<String> {
}) })
} }
/// The `merge-base` tag of a PR event (most recent common ancestor with the /// The `merge-base` tag of a PR event, as hex.
/// target branch), as hex. /// The most recent common ancestor with the target branch.
fn merge_base_of(event: &Event) -> Option<String> { fn merge_base_of(event: &Event) -> Option<String> {
event event
.tags .tags
@@ -1199,8 +1199,8 @@ fn merge_base_of(event: &Event) -> Option<String> {
}) })
} }
/// The `clone` tag of a PR event (URLs where the proposed branch can be /// The `clone` tag of a PR event.
/// fetched), or `None` if the PR has none. /// URLs where the proposed branch can be fetched, or `None` if the PR has none.
fn clone_urls_of(event: &Event) -> Option<Vec<String>> { fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
event event
.tags .tags
@@ -1222,9 +1222,10 @@ fn branch_name_of(event: &Event) -> Option<String> {
}) })
} }
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22 /// The latest PR update, kind 1619, revising `root`.
/// `E` tag pointing at the root PR event. Only updates by the PR author /// Found via its NIP-22 `E` tag pointing at the root PR event.
/// count: the tip of a PR is only mutable by its author (NIP-34). /// Only updates by the PR author count.
/// The tip of a PR is only mutable by its author, NIP-34.
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> { fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex(); let root_hex = root.id.to_hex();
events events
@@ -1238,8 +1239,8 @@ fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> O
.max_by_key(|e| e.created_at) .max_by_key(|e| e.created_at)
} }
/// One-line commit metadata for the commits list: author and relative time, /// One-line commit metadata for the commits list.
/// whichever is available. /// Author and relative time, whichever is available.
fn commit_meta(commit: &FileCommit) -> String { fn commit_meta(commit: &FileCommit) -> String {
let author = commit.author.trim(); let author = commit.author.trim();
let time = commit.time > 0; let time = commit.time > 0;
@@ -1357,8 +1358,7 @@ mod tests {
created_at, created_at,
) )
}; };
// An update revising a different PR must be ignored even though it // An update revising a different PR must be ignored even though it is newer.
// is newer.
let unrelated = signed( let unrelated = signed(
Kind::GitPullRequestUpdate, Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
@@ -1386,8 +1386,8 @@ mod tests {
.finalize(&other) .finalize(&other)
.expect("signed event"); .expect("signed event");
// The tip of a PR is only mutable by its author: a newer update // The tip of a PR is only mutable by its author.
// from anyone else must not win. // A newer update from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
} }
@@ -25,12 +25,11 @@ use super::new_pull_request::open_new_pull_panel;
use super::pull_request_detail::PullRequestDetailView; use super::pull_request_detail::PullRequestDetailView;
use super::send_patch::open_send_patch_panel; use super::send_patch::open_send_patch_panel;
/// Height of one pull request row in the virtual list; same layout as an /// Height of one pull request row in the virtual list.
/// issue row. /// Same layout as an issue row.
const PR_ROW_HEIGHT: f32 = 73.; const PR_ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter /// Status filter of the pull request list, chosen via the header's filter buttons.
/// buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullRequestFilter { enum PullRequestFilter {
/// Every pull request, regardless of status. /// Every pull request, regardless of status.
@@ -70,17 +69,18 @@ pub struct PullRequestsView {
filter: PullRequestFilter, filter: PullRequestFilter,
/// Per-row heights of the virtual list. /// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered /// The filtered pull request count [`Self::item_sizes`] was built for.
/// pull request count); rebuilt on change. /// Rebuilt on change.
pr_len: usize, pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`] /// Indices into the store's `pull_requests` matching [`Self::filter`].
/// (root PR events only; updates are revisions of the root); the /// Root PR events only, updates are revisions of the root.
/// virtual list renders this slice. Rebuilt only when the store /// The virtual list renders this slice.
/// version or the filter changes, keyed by [`Self::cache_key`]. /// Rebuilt only when the store version or the filter changes.
/// Keyed by [`Self::cache_key`].
visible_prs: Vec<usize>, visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)` of the root /// Header counts `(total, open, closed, draft, merged)`.
/// pull requests only (revisions are not separate PRs), rebuilt with /// Root pull requests only, revisions are not separate PRs.
/// [`Self::visible_prs`]. /// Rebuilt with [`Self::visible_prs`].
counts: (usize, usize, usize, usize, usize), counts: (usize, usize, usize, usize, usize),
/// Store version and filter the cached rows/counts were built from. /// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>, cache_key: Option<(u64, PullRequestFilter)>,
@@ -112,7 +112,7 @@ impl PullRequestsView {
} }
} }
/// Open the detail panel of `pr_id` at the bottom of the dock area. /// Open the detail panel of `pr_id` in the dock area.
fn open_pull_request_detail( fn open_pull_request_detail(
&mut self, &mut self,
pr_id: EventId, pr_id: EventId,
@@ -138,8 +138,8 @@ impl PullRequestsView {
}); });
} }
/// Render one row of the pull request list; `ix` is the row index and /// Render one row of the pull request list.
/// `pr_ix` the index of the pull request in the store's `pull_requests`. /// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement { fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let pr = &self.store.read(cx).pull_requests[pr_ix]; let pr = &self.store.read(cx).pull_requests[pr_ix];
let pr_id = pr.id; let pr_id = pr.id;
@@ -204,8 +204,8 @@ impl PullRequestsView {
} }
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild (`render` rebuilds first when the // Counts of the last list rebuild.
// store version or filter changed, so this is never stale). // `render` rebuilds first when the store version or filter changed, so never stale.
let (total, open, closed, draft, merged) = self.counts; let (total, open, closed, draft, merged) = self.counts;
h_flex() h_flex()
@@ -340,8 +340,8 @@ impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter; let filter = self.filter;
// Rebuild the filtered rows and header counts only when the store // Rows and counts are rebuilt only when the store refreshed or filter changed.
// refreshed or the filter changed; other renders reuse the cache. // Other renders reuse the cache.
let version = self.store.read(cx).version(); let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) { if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx); let store = self.store.read(cx);
@@ -351,10 +351,10 @@ impl Render for PullRequestsView {
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(ix, pr)| { .filter_map(|(ix, pr)| {
// Kind-30620 patches are revisions of a root PR (NIP-34), // Kind-30620 patches are revisions of a root PR, NIP-34.
// not separate pull requests: count only root events, or // They are not separate pull requests.
// the header counts inflate with every revision (which // Count root events only, or the counts inflate with every revision.
// also default to `Open` in `status_of`). // Revisions also default to `Open` in `status_of`.
if pr.kind != Kind::GitPullRequest { if pr.kind != Kind::GitPullRequest {
return None; return None;
} }
@@ -375,8 +375,8 @@ impl Render for PullRequestsView {
let count = self.visible_prs.len(); let count = self.visible_prs.len();
// The virtual list's item count comes from `item_sizes`; rebuild it // The virtual list's item count comes from `item_sizes`.
// whenever the filtered pull request count changes. // Rebuild it whenever the filtered pull request count changes.
if count != self.pr_len { if count != self.pr_len {
self.pr_len = count; self.pr_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]); self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]);
@@ -386,8 +386,8 @@ impl Render for PullRequestsView {
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone(); let view = cx.entity().clone();
// Non-fatal warnings and errors of the last action (e.g. creating // Non-fatal warnings and errors of the last action, like creating or updating a PR.
// or updating a PR), shown as dismissible banners above the list. // Shown as dismissible banners above the list.
let (last_error, last_warning) = { let (last_error, last_warning) = {
let store = self.store.read(cx); let store = self.store.read(cx);
(store.last_error.clone(), store.last_warning.clone()) (store.last_error.clone(), store.last_warning.clone())
@@ -19,15 +19,15 @@ pub struct SendPatchView {
store: Entity<RepoStore>, store: Entity<RepoStore>,
/// Display name of the repository, for the panel title. /// Display name of the repository, for the panel title.
repo_name: SharedString, repo_name: SharedString,
/// Title input (required). /// Title input, required.
subject: Entity<InputState>, subject: Entity<InputState>,
/// Description input (optional). /// Description input, optional.
description: Entity<TextareaState>, description: Entity<TextareaState>,
/// The pasted `git format-patch` output (required). /// The pasted `git format-patch` output, required.
patch: Entity<TextareaState>, patch: Entity<TextareaState>,
/// A submit is in flight. /// A submit is in flight.
submitting: bool, submitting: bool,
/// Error of the last submit attempt (keeps the panel open). /// Error of the last submit attempt, it keeps the panel open.
error: Option<SharedString>, error: Option<SharedString>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -71,10 +71,11 @@ impl SendPatchView {
} }
} }
/// Publish the pull request from the pasted patch. The store validates /// Publish the pull request from the pasted patch.
/// synchronously (patch shape, per-part size, sign-in); on failure the /// The store validates synchronously, patch shape, per-part size and sign-in.
/// panel stays open with the error inline, on success it closes — async /// On failure the panel stays open with the error inline.
/// publish failures surface in the pull request list's banner. /// On success it closes.
/// Async publish failures surface in the pull request list's banner.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting { if self.submitting {
return; return;
@@ -93,8 +94,8 @@ impl SendPatchView {
self.error = None; self.error = None;
cx.notify(); cx.notify();
// Errors the store detects before publishing are returned // Errors the store detects before publishing.
// synchronously through `last_error`. // Returned synchronously through `last_error`.
let sync_error = store.update(cx, |store, cx| { let sync_error = store.update(cx, |store, cx| {
store.open_pull_request( store.open_pull_request(
(!subject.is_empty()).then_some(subject), (!subject.is_empty()).then_some(subject),
+20 -21
View File
@@ -24,13 +24,13 @@ use super::open_repo_panel;
const COLUMNS: usize = 2; const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
/// How many of the newest repositories the "Recent" sort shows. /// How many of the newest repositories the `Recent` sort shows.
const RECENT_COUNT: usize = 10; const RECENT_COUNT: usize = 10;
/// Sort of the explore list, chosen via the header's filter buttons. /// Sort of the explore list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum RepoFilter { enum RepoFilter {
/// Every repository, newest first (the store's default order). /// Every repository in the store's default order, newest first.
All, All,
#[default] #[default]
/// Repositories ranked by total issues + pull requests + commits. /// Repositories ranked by total issues + pull requests + commits.
@@ -40,15 +40,15 @@ enum RepoFilter {
} }
impl RepoFilter { impl RepoFilter {
/// Indices into the store's `announcements` included by this filter, in /// Indices into the store's `announcements` this filter includes, in display order.
/// display order, narrowed to repositories whose name (or id) contains /// Narrowed to repositories whose name or id contains `query`.
/// `query`; an empty query matches everything. /// An empty query matches everything.
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> { fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
let announcements = &store.announcements; let announcements = &store.announcements;
let mut indices: Vec<usize> = (0..announcements.len()).collect(); let mut indices: Vec<usize> = (0..announcements.len()).collect();
// Narrow by the search query first, so "Recent" limits the matches // Narrow by the search query first.
// and "Popular" ranks them. // Recent then limits the matches and Popular ranks them.
let query = query.trim().to_lowercase(); let query = query.trim().to_lowercase();
if !query.is_empty() { if !query.is_empty() {
indices.retain(|&ix| { indices.retain(|&ix| {
@@ -93,10 +93,10 @@ pub struct RepoListView {
filter: RepoFilter, filter: RepoFilter,
/// Per-row heights of the virtual list. /// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count). /// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
repo_len: usize, repo_len: usize,
/// Indices into the store's `announcements` matching [`Self::filter`], /// Indices matching [`Self::filter`] into the store's `announcements`.
/// in display order; the virtual list renders this slice. /// The virtual list renders this slice in display order.
visible: Vec<usize>, visible: Vec<usize>,
/// Search box filtering repositories by name. /// Search box filtering repositories by name.
search: Entity<InputState>, search: Entity<InputState>,
@@ -121,8 +121,8 @@ impl RepoListView {
} }
}); });
// Keep the visible slice and row sizes in sync with the store, // Keep the visible slice and row sizes in sync with the store.
// so newly announced repositories appear without waiting for a click. // Newly announced repositories appear without waiting for a click.
let subscription = cx.observe(&store, |this, _store, cx| { let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild_rows(cx); this.rebuild_rows(cx);
}); });
@@ -141,16 +141,16 @@ impl RepoListView {
_subscription: subscription, _subscription: subscription,
}; };
// Seed the rows right away; the store may already hold announcements // Seed the rows right away.
// (it loaded before the panel opened), and the first render must not // The store may already hold announcements from before the panel opened.
// depend on a later store update. // The first render must not depend on a later store update.
this.rebuild_rows(cx); this.rebuild_rows(cx);
this this
} }
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
/// store contents, [`Self::filter`] and the search query. /// Uses the store contents, [`Self::filter`] and the search query.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) { fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter; let filter = self.filter;
let query = self.search.read(cx).value(); let query = self.search.read(cx).value();
@@ -201,8 +201,8 @@ impl RepoListView {
.map(|label| SharedString::from(format!("Updated {label}"))) .map(|label| SharedString::from(format!("Updated {label}")))
.unwrap_or_default(); .unwrap_or_default();
// Fork badge: the upstream's display name when its announcement is // The fork badge shows the upstream name when its announcement is known locally.
// known locally, otherwise its repository id from the `u` tag. // Otherwise it shows the repository id from the `u` tag.
let fork_label: Option<SharedString> = let fork_label: Option<SharedString> =
announcement.upstream.as_ref().and_then(|upstream| { announcement.upstream.as_ref().and_then(|upstream| {
let addr = upstream.addr.as_ref()?; let addr = upstream.addr.as_ref()?;
@@ -347,8 +347,7 @@ impl RepoListView {
.into_any_element() .into_any_element()
} }
/// One segmented filter button of the header, styled like the issues /// One segmented header filter button, like the issues list's status filter buttons.
/// list's status filter buttons.
fn filter_button( fn filter_button(
&self, &self,
filter: RepoFilter, filter: RepoFilter,
@@ -24,10 +24,9 @@ pub struct CreateRepoState {
} }
/// Open the Create Repository dialog. /// Open the Create Repository dialog.
/// /// Loads the user's default grasp servers, a kind `10317` grasp list.
/// The dialog loads the user's default grasp servers (kind `10317` grasp /// Falls back to the shared defaults when none are set.
/// list) and falls back to the shared defaults when none are set. On /// On success the dialog closes and the new repository opens in the dock.
/// success the dialog closes and the new repository opens in the dock.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) { pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let settings = SettingsStore::global(cx); let settings = SettingsStore::global(cx);
let default_folder = settings let default_folder = settings
@@ -160,10 +159,9 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
}); });
} }
/// Prompt the user to pick the folder the repository will be stored in, using /// Pick the repository's storage folder with the platform's native folder picker.
/// the platform's native folder picker, and show the result in the disabled /// Show the result in the disabled folder input.
/// folder input. The picked folder is remembered in the settings so it /// The settings remember the picked folder as the default next time.
/// becomes the default next time.
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) { fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle(); let handle = window.window_handle();
let folder_input = folder_input.clone(); let folder_input = folder_input.clone();
@@ -198,8 +196,8 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach(); .detach();
} }
/// Run the create-repository flow, /// Run the create-repository flow.
/// opens the new working copy and the repository panel on success. /// Opens the new working copy and the repository panel on success.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn create_repository( fn create_repository(
name_input: Entity<InputState>, name_input: Entity<InputState>,
@@ -247,8 +245,8 @@ fn create_repository(
Ok((announcement, local_path)) => { Ok((announcement, local_path)) => {
cx.update_window(handle, |_, window, cx| { cx.update_window(handle, |_, window, cx| {
window.close_dialog(cx); window.close_dialog(cx);
// Remember the new working copy as a checkout of this // Record the new working copy as a checkout of this repository.
// repository, so the New PR panel pre-fills it. // The New PR panel then pre-fills it.
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| { checkouts.update(cx, |store, cx| {
store.record(local_path.clone(), announcement.addr(), cx); store.record(local_path.clone(), announcement.addr(), cx);
@@ -9,24 +9,21 @@ use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_core::filters; use signed_core::filters;
use signed_state::Backend; use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async /// State of the grasp-server section of a publish dialog, so async results can be rendered.
/// results can be rendered.
#[derive(Default)] #[derive(Default)]
pub struct GraspServersState { pub struct GraspServersState {
/// The user's grasp list (kind `10317`) is being loaded. /// The user's grasp list of kind `10317` is being loaded.
pub loading_servers: bool, pub loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>, pub grasp_servers: Vec<RelayUrl>,
/// Whether the grasp server section is shown; defaults to shown. /// Whether the grasp server section is shown. Defaults to shown.
pub servers_enabled: bool, pub servers_enabled: bool,
/// Error of the last grasp-server edit (e.g. an invalid relay URL). /// Error of the last grasp-server edit, an invalid relay URL for example.
pub error: Option<SharedString>, pub error: Option<SharedString>,
} }
impl GraspServersState { impl GraspServersState {
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers. /// Defaults used until the user's grasp list loads, which replaces them when non-empty.
/// /// Persisted settings supply the defaults, an empty list falls back to the built-ins.
/// The servers come from the persisted settings, falling back to the
/// built-in defaults when the configured list is empty.
pub fn new_default(settings: &GraspServersSettings) -> Self { pub fn new_default(settings: &GraspServersSettings) -> Self {
let urls: Vec<String> = if settings.default_servers.is_empty() { let urls: Vec<String> = if settings.default_servers.is_empty() {
DEFAULT_GRASP_SERVERS DEFAULT_GRASP_SERVERS
@@ -48,10 +45,9 @@ impl GraspServersState {
} }
} }
/// The "Grasp servers" form field shared by the publish dialogs: an /// The Grasp servers form field shared by the publish dialogs.
/// expandable toggle, the configured servers (each removable) and an /// An expandable toggle, the configured servers each removable, and an add-relay input.
/// add-relay input, with a loading hint while the user's grasp list /// Shows a loading hint while the user's kind `10317` grasp list is fetched.
/// (kind `10317`) is being fetched.
pub fn grasp_servers_field( pub fn grasp_servers_field(
state: &Entity<GraspServersState>, state: &Entity<GraspServersState>,
relay_input: &Entity<InputState>, relay_input: &Entity<InputState>,
@@ -143,7 +139,7 @@ pub fn grasp_servers_field(
})) }))
} }
/// A grasp server row: the host as a tag plus a remove button. /// One grasp server row, the host in a tag plus a remove button.
fn render_server_row( fn render_server_row(
ix: usize, ix: usize,
relay: &RelayUrl, relay: &RelayUrl,
@@ -182,7 +178,7 @@ fn render_server_row(
) )
} }
/// The bare host of a grasp server (defaults are entered without a scheme). /// The bare host of a grasp server, defaults are entered without a scheme.
fn display_server(relay: &RelayUrl) -> SharedString { fn display_server(relay: &RelayUrl) -> SharedString {
relay relay
.domain() .domain()
@@ -190,7 +186,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
.unwrap_or_else(|| SharedString::from(relay.to_string())) .unwrap_or_else(|| SharedString::from(relay.to_string()))
} }
/// Parse the relay input (accepting a bare host) and append it to the list. /// Parse the relay input, accepting a bare host, and append it to the list.
fn add_relay( fn add_relay(
state: &Entity<GraspServersState>, state: &Entity<GraspServersState>,
input: &Entity<InputState>, input: &Entity<InputState>,
@@ -226,8 +222,8 @@ fn add_relay(
} }
} }
/// Load the user's grasp list (kind `10317`) from the local database and /// Load the user's grasp list of kind `10317` from the local database.
/// replace the defaults with it when it lists any servers. /// It replaces the defaults when it lists any servers.
pub fn load_user_grasp_servers( pub fn load_user_grasp_servers(
state: Entity<GraspServersState>, state: Entity<GraspServersState>,
window: &mut Window, window: &mut Window,
@@ -2,8 +2,7 @@ use gpui::{App, Window, px};
use gpui_component::WindowExt; use gpui_component::WindowExt;
/// Open the Import Identity dialog. /// Open the Import Identity dialog.
/// /// Currently a placeholder, the dialog only shows a title.
/// Currently a placeholder — the dialog only shows a title for now.
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.)) dialog.title("Import identity").width(px(400.))
+36 -39
View File
@@ -32,25 +32,25 @@ mod settings_dialog;
use self::onboarding_dialog::OnboardingState; use self::onboarding_dialog::OnboardingState;
/// Left-dock panel with navigation entries. Entries open content panels in /// Left-dock panel with navigation entries.
/// the dock area. /// Entries open content panels in the dock area.
pub struct SidebarPanel { pub struct SidebarPanel {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
logged_in: bool, logged_in: bool,
/// Repositories announced by the current user, listed under /// Repositories the current user announced, listed under the All Repositories heading.
/// "All Repositories". Recreated when the signer changes. /// Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>, my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders. /// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>, my_repos_subscription: Option<Subscription>,
/// Banner artwork shown behind the sign-in screen, /// Banner artwork behind the sign-in screen.
/// picked at random from the bundled `backgrounds/` assets. /// Picked at random from the bundled `backgrounds/` assets.
banner: SharedString, banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render. /// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription, _local_repos_subscription: Subscription,
/// Observes the checkouts store, whose ready-to-push statuses feed the /// Observes the checkouts store.
/// badges on the user's repository rows. /// Its ready-to-push statuses feed the badges on the user's repo rows.
_checkouts_subscription: Subscription, _checkouts_subscription: Subscription,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -107,8 +107,8 @@ impl SidebarPanel {
panel panel
} }
/// (Re)create the store listing the current user's repositories, /// Recreate the store listing the current user's repositories.
/// and watch each of them for unpushed local work. /// Watch each repository for unpushed local work.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) { fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None; self.my_repos_subscription = None;
@@ -119,9 +119,9 @@ impl SidebarPanel {
if let Some(store) = self.my_repos.as_ref() { if let Some(store) = self.my_repos.as_ref() {
self.my_repos_subscription = Some(cx.observe(store, |_this, store, cx| { self.my_repos_subscription = Some(cx.observe(store, |_this, store, cx| {
cx.notify(); cx.notify();
// These are the signed-in user's own repositories; request // These are the signed-in user's own repositories.
// their ready-to-push statuses (deduplicated per repo) so // Request their ready-to-push statuses, deduplicated per repository.
// the rows carry a badge while local work is unpushed. // The rows carry a badge while local work is unpushed.
let addrs: Vec<_> = store let addrs: Vec<_> = store
.read(cx) .read(cx)
.announcements .announcements
@@ -138,8 +138,8 @@ impl SidebarPanel {
} }
} }
/// Open the Explore (repository list) panel in the center of the dock /// Open the Explore repository list panel in the dock area's center.
/// area. No-op if it's already open. /// No-op if it is already open.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self if self
.explore .explore
@@ -191,8 +191,8 @@ impl SidebarPanel {
open_repo_panel(&self.dock_area, announcement, window, &mut *cx); open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
} }
/// Open a local repository's detail view in the dock's center; the /// Open a local repository's detail view in the dock's center.
/// detail view offers to publish it to NIP-34. /// The detail view offers to publish it to NIP-34.
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) { fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let detail = let detail =
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
@@ -208,10 +208,10 @@ impl SidebarPanel {
}); });
} }
/// The "All Repositories" section: header with the create button and /// The All Repositories section of the sidebar.
/// the current user's repositories below it, lazily rendered through a /// A header with the create button above the current user's repositories.
/// [`uniform_list`], followed by the local git repositories discovered /// Rendered lazily through a [`uniform_list`].
/// by the startup scan. /// Followed by local git repositories from the startup scan.
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref(); let store = self.my_repos.as_ref();
let local = LocalReposStore::global(cx); let local = LocalReposStore::global(cx);
@@ -265,11 +265,10 @@ impl SidebarPanel {
) )
.when_some(store, |builder, store| { .when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone(); let announcements = store.read(cx).announcements.clone();
// Local repositories that have already been published to // Local repositories already published to NIP-34 appear above.
// NIP-34 are listed among the user's repositories above; // Hide them from the local section here.
// hide them from the local section (matched by the // Matched by the identifier derived from the directory name.
// identifier derived from the directory name, like the // Same derivation as the init dialog's default name.
// init dialog's default name).
let announced_ids: HashSet<String> = let announced_ids: HashSet<String> =
announcements.iter().map(|a| a.id.clone()).collect(); announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = local_repos let local_repos: Vec<PathBuf> = local_repos
@@ -282,8 +281,8 @@ impl SidebarPanel {
}) })
.cloned() .cloned()
.collect(); .collect();
// One merged list: the user's NIP-34 repositories first, // One merged list, the user's NIP-34 repositories first.
// then the local repositories discovered by the scan. // Local repositories discovered by the scan follow.
let total = announcements.len() + local_repos.len(); let total = announcements.len() + local_repos.len();
if total == 0 { if total == 0 {
@@ -326,8 +325,7 @@ impl SidebarPanel {
}) })
} }
/// One row of the merged sidebar list: a NIP-34 repository or a local /// One row of the merged sidebar list, a NIP-34 or a local repository.
/// repository.
fn render_repo_row_at( fn render_repo_row_at(
&self, &self,
announcements: &[Announcement], announcements: &[Announcement],
@@ -358,8 +356,8 @@ impl SidebarPanel {
.unwrap_or_else(|| SharedString::from(announcement.id.clone())); .unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
// A small badge with the unpushed commit count of the repository's // Badge with the unpushed commit count of the repository's local checkouts.
// local checkouts (ready to push to the grasp servers). // The commits are ready to push to the grasp servers.
let unpushed: usize = CheckoutsStore::global(cx) let unpushed: usize = CheckoutsStore::global(cx)
.read(cx) .read(cx)
.push_statuses_of(&announcement.addr()) .push_statuses_of(&announcement.addr())
@@ -379,10 +377,9 @@ impl SidebarPanel {
) )
} }
/// One local repository row: a deterministic pixel avatar seeded from /// One local repository row, a deterministic pixel avatar seeded from the path.
/// the path, the directory name, and a warning suffix marking it as /// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
/// not yet set up for NIP-34. Clicking it opens the repository's /// Clicking opens the detail view, which offers to initialize it.
/// detail view, which offers to initialize it.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement { fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path let name = path
.file_name() .file_name()
@@ -410,7 +407,7 @@ impl SidebarPanel {
import_dialog::open(window, cx); import_dialog::open(window, cx);
} }
/// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area. /// Render the user avatar and name in the sidebar, inside the titlebar drag area.
fn render_user( fn render_user(
&self, &self,
profile: &Profile, profile: &Profile,
@@ -440,8 +437,8 @@ impl SidebarPanel {
) )
} }
/// Sign-in placeholder shown while logged out: banner artwork behind a /// Sign-in placeholder shown while logged out.
/// scrim so the CTA buttons stay readable in both themes. /// Banner artwork behind a scrim keeps the CTA buttons readable in both themes.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div { fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex() v_flex()
.size_full() .size_full()
@@ -15,10 +15,8 @@ pub struct OnboardingState {
} }
/// Open the Onboarding dialog for creating a new identity. /// Open the Onboarding dialog for creating a new identity.
/// /// The caller creates the input and state entities and passes them in.
/// The caller is responsible for creating the input and state entities and /// This function only builds the dialog UI and wires up the continue-button handler.
/// passing them in. This function only builds the dialog UI and wires up
/// the continue-button handler.
pub fn open( pub fn open(
name_input: Entity<InputState>, name_input: Entity<InputState>,
pass_input: Entity<InputState>, pass_input: Entity<InputState>,
@@ -17,9 +17,8 @@ pub struct PassphraseState {
_enter_subscription: Option<Subscription>, _enter_subscription: Option<Subscription>,
} }
/// Open the dialog asking for the passphrase that protects the stored /// Open the dialog asking for the passphrase that protects the stored identity.
/// NIP-49 encrypted identity (`ncryptsec1...`). /// The identity is NIP-49 encrypted, for example `ncryptsec1...`.
///
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`]. /// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let pass_input = cx.new(|cx| { let pass_input = cx.new(|cx| {
@@ -98,8 +97,9 @@ pub fn open(window: &mut Window, cx: &mut App) {
}); });
} }
/// Submit the passphrase to the backend. On success the dialog is closed; /// Submit the passphrase to the backend.
/// on failure the error is rendered inline and the dialog stays open. /// On success the dialog closes.
/// On failure the error is rendered inline and the dialog stays open.
fn unlock( fn unlock(
pass_input: &Entity<InputState>, pass_input: &Entity<InputState>,
state: &Entity<PassphraseState>, state: &Entity<PassphraseState>,
@@ -1,10 +1,3 @@
//! The Settings dialog, opened from the sidebar's Settings entry.
//!
//! A custom settings layout that divides related settings into sections
//! separated by simple horizontal lines — no `GroupBox` boxes and no settings
//! navigation sidebar. Every control edits the persisted [`SettingsStore`]
//! and applies the change to the live theme immediately.
use std::cell::Cell; use std::cell::Cell;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
@@ -55,8 +48,8 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
(light, dark) (light, dark)
} }
/// Stateful controls of the settings dialog, created once when it opens so /// Stateful controls of the settings dialog, created once when it opens.
/// their values survive re-renders of the dialog content. /// Their values survive re-renders of the dialog content.
struct SettingsControls { struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>, appearance: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>, light_theme: Entity<SelectState<Vec<SelectOption>>>,
@@ -66,8 +59,7 @@ struct SettingsControls {
radius: Entity<InputState>, radius: Entity<InputState>,
radius_lg: Entity<InputState>, radius_lg: Entity<InputState>,
grasp_server_input: Entity<InputState>, grasp_server_input: Entity<InputState>,
/// The effective default create-repository folder, shown in the disabled /// The effective default create-repository folder, shown in the disabled input.
/// folder selector.
default_folder: Entity<InputState>, default_folder: Entity<InputState>,
/// Keeps the control subscriptions alive for the dialog's lifetime. /// Keeps the control subscriptions alive for the dialog's lifetime.
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
@@ -296,8 +288,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
}); });
} }
/// The settings content: one section per related setting, divided by /// The settings content, one section per related setting.
/// horizontal separator lines. /// Sections are divided by horizontal separator lines.
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement { fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
let store = SettingsStore::global(cx); let store = SettingsStore::global(cx);
let settings = store.read(cx).settings().clone(); let settings = store.read(cx).settings().clone();
@@ -325,8 +317,7 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
)) ))
} }
/// Theme configuration: the theme names in the registry plus /// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
/// the visual tweaks the application customizes at startup.
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex() v_flex()
.gap_3() .gap_3()
@@ -397,7 +388,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
)) ))
} }
/// The default grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. /// Default grasp servers offered until the user publishes a kind `10317` grasp list.
fn grasp_servers_section( fn grasp_servers_section(
settings: &Settings, settings: &Settings,
controls: &SettingsControls, controls: &SettingsControls,
@@ -413,8 +404,8 @@ fn grasp_servers_section(
)) ))
} }
/// The editable list of default grasp servers plus an add-relay input, /// The editable list of default grasp servers plus an add-relay input.
/// styled like the grasp-server section of the publish dialogs. /// Styled like the grasp-server section of the publish dialogs.
fn grasp_server_editor( fn grasp_server_editor(
servers: &[String], servers: &[String],
controls: &SettingsControls, controls: &SettingsControls,
@@ -478,8 +469,8 @@ fn grasp_server_editor(
) )
} }
/// The bare host of a grasp server (defaults are entered without a scheme), /// The bare host of a grasp server, defaults are entered without a scheme.
/// matching how the publish dialogs display servers. /// Matches how the publish dialogs display servers.
fn display_server(server: &str) -> SharedString { fn display_server(server: &str) -> SharedString {
RelayUrl::parse(server) RelayUrl::parse(server)
.ok() .ok()
@@ -513,8 +504,8 @@ fn repositories_section(
)) ))
} }
/// The editable list of scan directories plus an add-directory button, /// The editable list of scan directories plus an add-directory button.
/// styled like the grasp-server list. /// Styled like the grasp-server list.
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
v_flex() v_flex()
.w_full() .w_full()
@@ -566,8 +557,8 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
) )
} }
/// The default-folder selector: a disabled input showing the effective /// The default-folder selector, a disabled input plus a picker button.
/// folder plus a picker button, matching the create-repository dialog. /// Matches the create-repository dialog.
fn folder_selector(controls: &SettingsControls) -> impl IntoElement { fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
let default_folder = controls.default_folder.clone(); let default_folder = controls.default_folder.clone();
h_flex() h_flex()
@@ -590,8 +581,8 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
) )
} }
/// Parse the server input (accepting a bare host) and append it to the /// Parse the server input and append it to the default grasp servers.
/// default grasp servers. /// A bare host is accepted.
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) { fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let value = input.read(cx).value().trim().to_owned(); let value = input.read(cx).value().trim().to_owned();
if value.is_empty() { if value.is_empty() {
@@ -660,8 +651,8 @@ fn add_scan_path(cx: &mut App) {
.detach(); .detach();
} }
/// Prompt for the folder the Create Repository dialog should default to, /// Prompt for the Create Repository dialog's default folder.
/// remembering it in the settings and showing it in the disabled input. /// Remember it in the settings and show it in the disabled input.
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) { fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle(); let handle = window.window_handle();
let default_folder = default_folder.clone(); let default_folder = default_folder.clone();
@@ -695,8 +686,9 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
.detach(); .detach();
} }
/// Wire a number input to the settings: steps clamp and persist, typed /// Wire a number input to the settings.
/// changes parse, clamp and persist. /// Step actions clamp and persist the value.
/// Typed changes parse, clamp and persist.
fn wire_number_input( fn wire_number_input(
state: &Entity<InputState>, state: &Entity<InputState>,
subscriptions: &mut Vec<Subscription>, subscriptions: &mut Vec<Subscription>,
+8 -10
View File
@@ -45,10 +45,9 @@ impl Workspace {
let mut subscriptions = vec![]; let mut subscriptions = vec![];
// A bottom/right dock whose last panel was dragged away is removed // A bottom or right dock whose last panel was dragged away is removed entirely.
// entirely: base keeps the emptied region, which would otherwise // The emptied region would otherwise linger as a bare strip.
// linger as a bare strip. Deferred, because the event arrives while // The removal is deferred, the event arrives while the area is mid-update.
// the area is mid-update.
let dock_for_pruning = dock.clone(); let dock_for_pruning = dock.clone();
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&dock, &dock,
@@ -78,9 +77,8 @@ impl Workspace {
let backend = Backend::global(cx); let backend = Backend::global(cx);
// Ask for the passphrase when the stored identity is NIP-49 // Ask for the passphrase when the stored identity is NIP-49 encrypted.
// encrypted. Subscribed via the window, since opening a dialog // Subscribed via the window, since opening a dialog needs a window.
// needs one.
let passphrase_subscription = let passphrase_subscription =
window.subscribe(&backend, cx, |_backend, event, window, cx| { window.subscribe(&backend, cx, |_backend, event, window, cx| {
if matches!(event, BackendEvent::PassphraseRequired) { if matches!(event, BackendEvent::PassphraseRequired) {
@@ -88,9 +86,9 @@ impl Workspace {
} }
}); });
// The event may have fired before this window existed (the backend // The event may have fired before this window existed.
// is initialized before the first window opens); fall back to the // The backend is initialized before the first window opens.
// backend state in that case. // Fall back to the backend state in that case.
if backend.read(cx).passphrase_required() { if backend.read(cx).passphrase_required() {
passphrase_dialog::open(window, cx); passphrase_dialog::open(window, cx);
} }
+4 -4
View File
@@ -17,14 +17,14 @@ fn main() {
gpui_component::init(cx); gpui_component::init(cx);
theme::init(cx); theme::init(cx);
// Load the persisted settings before applying the theme, // Load the persisted settings before applying the theme.
// so the stored appearance and theme configuration take effect at startup. // Stored appearance and theme settings then take effect at startup.
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx)); let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
SettingsStore::set_global(store.clone(), cx); SettingsStore::set_global(store.clone(), cx);
let settings = store.read(cx).settings().clone(); let settings = store.read(cx).settings().clone();
// Register the built-in "Signed" theme (light + dark variants) // Register the built-in Signed theme, light and dark variants.
// and make it the active theme, following the stored appearance. // The stored appearance then selects the active theme.
let registry = ThemeRegistry::global_mut(cx); let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() { for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) { if let Err(err) = registry.load_themes_from_str(&content) {