Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a87030e929 | ||
|
|
272ddf2674 | ||
|
|
fa50e9f153 | ||
|
|
da22d26019 | ||
|
|
0ff6740a9e | ||
|
|
e2bce4e074 |
@@ -81,6 +81,8 @@ Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future
|
|||||||
|
|
||||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||||
|
|
||||||
|
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
||||||
|
|
||||||
## Elements
|
## Elements
|
||||||
|
|
||||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||||
|
|||||||
@@ -6,12 +6,29 @@
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
|
||||||
|
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
|
||||||
|
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
||||||
|
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
||||||
|
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
||||||
|
- Connect to bootstrap relays on demand instead of at startup
|
||||||
|
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
|
||||||
|
- Prefetch the 500 most recent profiles at startup instead of 200
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
|
||||||
|
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
|
||||||
|
- Fix background tasks outliving their view, so a closed repository panel or pull request view stops fetching and publishing on its own
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
## v0.1.0-alpha - 2026/09/14
|
## v0.1.0-alpha - 2026/09/14
|
||||||
|
|||||||
Generated
+561
-514
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -4,20 +4,18 @@ members = ["crates/*", "desktop"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0-alpha"
|
version = "0.2.0-alpha"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# GPUI
|
gpui = { package = "gpui-pre", version = "0.3.6" }
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" }
|
||||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|
||||||
|
|
||||||
# GPUI Kit
|
# GPUI Kit
|
||||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] }
|
||||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
gpui-base = "0.6.6"
|
||||||
|
|
||||||
dock = { path = "crates/dock" }
|
dock = { path = "crates/dock" }
|
||||||
settings = { path = "crates/settings" }
|
settings = { path = "crates/settings" }
|
||||||
|
|||||||
@@ -77,9 +77,9 @@
|
|||||||
"success.foreground": "#052E16",
|
"success.foreground": "#052E16",
|
||||||
"switch.background": "#CECECE",
|
"switch.background": "#CECECE",
|
||||||
"switch.thumb.background": "#FFFFFF",
|
"switch.thumb.background": "#FFFFFF",
|
||||||
"tab.background": "#00000000",
|
"tab.background": "#E8E8E8",
|
||||||
"tab.active.background": "#EBFFC1",
|
"tab.active.background": "#F5F5F5",
|
||||||
"tab.active.foreground": "#3F6212",
|
"tab.active.foreground": "#18181B",
|
||||||
"tab.foreground": "#646464",
|
"tab.foreground": "#646464",
|
||||||
"tab_bar.background": "#F0F0F0",
|
"tab_bar.background": "#F0F0F0",
|
||||||
"tab_bar.segmented.background": "#F0F0F0",
|
"tab_bar.segmented.background": "#F0F0F0",
|
||||||
@@ -286,9 +286,9 @@
|
|||||||
"success.foreground": "#052E16",
|
"success.foreground": "#052E16",
|
||||||
"switch.background": "#484848",
|
"switch.background": "#484848",
|
||||||
"switch.thumb.background": "#0A0A0A",
|
"switch.thumb.background": "#0A0A0A",
|
||||||
"tab.background": "#00000000",
|
"tab.background": "#2E2E2E",
|
||||||
"tab.active.background": "#19200A",
|
"tab.active.background": "#2A2A2A",
|
||||||
"tab.active.foreground": "#C6FF4D",
|
"tab.active.foreground": "#FAFAFA",
|
||||||
"tab.foreground": "#B4B4B4",
|
"tab.foreground": "#B4B4B4",
|
||||||
"tab_bar.background": "#191919",
|
"tab_bar.background": "#191919",
|
||||||
"tab_bar.segmented.background": "#191919",
|
"tab_bar.segmented.background": "#191919",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
gpui-base.workspace = true
|
gpui-base.workspace = true
|
||||||
|
settings = { path = "../settings" }
|
||||||
signed_ui = { path = "../signed_ui" }
|
signed_ui = { path = "../signed_ui" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -11,22 +11,19 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||||
PanelView, TabGroupRenderer, TilesRenderer,
|
PanelView, TabGroupRenderer,
|
||||||
};
|
};
|
||||||
use gpui_base::resize_handle;
|
use gpui_base::resize_handle;
|
||||||
use gpui_component::scroll::ScrollbarMode;
|
|
||||||
use gpui_component::{ActiveTheme as _, Side};
|
use gpui_component::{ActiveTheme as _, Side};
|
||||||
|
|
||||||
use crate::invalid_panel::InvalidPanel;
|
use crate::invalid_panel::InvalidPanel;
|
||||||
|
use crate::panel_handle;
|
||||||
use crate::tab_panel::SignedTabGroupSkin;
|
use crate::tab_panel::SignedTabGroupSkin;
|
||||||
use crate::tiles::SignedTilesSkin;
|
|
||||||
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
|
||||||
|
|
||||||
/// State the skin shares with its per-container renderers.
|
/// State the skin shares with its 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>,
|
||||||
tiles_scrollbar_mode: Cell<Option<ScrollbarMode>>,
|
|
||||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||||
resizing_dock: Cell<Option<DockPlacement>>,
|
resizing_dock: Cell<Option<DockPlacement>>,
|
||||||
}
|
}
|
||||||
@@ -40,10 +37,6 @@ impl SkinShared {
|
|||||||
self.toggle_button_visible.get()
|
self.toggle_button_visible.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
|
||||||
self.tiles_scrollbar_mode.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||||
&self.resizing_dock
|
&self.resizing_dock
|
||||||
}
|
}
|
||||||
@@ -73,7 +66,6 @@ impl SignedDockSkin {
|
|||||||
shared: Rc::new(SkinShared {
|
shared: Rc::new(SkinShared {
|
||||||
area: cx.weak_entity(),
|
area: cx.weak_entity(),
|
||||||
toggle_button_visible: Cell::new(true),
|
toggle_button_visible: Cell::new(true),
|
||||||
tiles_scrollbar_mode: Cell::new(None),
|
|
||||||
resizing_dock: Cell::new(None),
|
resizing_dock: Cell::new(None),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -92,16 +84,6 @@ impl SignedDockSkin {
|
|||||||
self.shared.toggle_button_visible.set(visible);
|
self.shared.toggle_button_visible.set(visible);
|
||||||
self.shared.notify(cx);
|
self.shared.notify(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a tiles canvas shows its scrollbar. `None` follows the theme.
|
|
||||||
pub fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
|
||||||
self.shared.tiles_scrollbar_mode()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_tiles_scrollbar_mode(&self, mode: Option<ScrollbarMode>, cx: &mut App) {
|
|
||||||
self.shared.tiles_scrollbar_mode.set(mode);
|
|
||||||
self.shared.notify(cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload a dock's resize handle drags.
|
/// Payload a dock's resize handle drags.
|
||||||
@@ -154,16 +136,10 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let placement = dock.placement();
|
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.relative()
|
||||||
// A closed bottom dock keeps a strip, and that strip is the tab bar.
|
|
||||||
.when(!dock.is_open() && placement.is_bottom(), |this| {
|
|
||||||
this.h(TAB_BAR_HEIGHT)
|
|
||||||
})
|
|
||||||
.child(content)
|
.child(content)
|
||||||
.child(self.render_resize_handle(dock, window, cx))
|
.child(self.render_resize_handle(dock, window, cx))
|
||||||
.child(DockResizeTracker {
|
.child(DockResizeTracker {
|
||||||
@@ -190,10 +166,6 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
|
||||||
Rc::new(SignedTilesSkin::new(self.shared().clone()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignedDockSkin {
|
impl SignedDockSkin {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use gpui_base::dock::PanelView;
|
|||||||
mod dock_area;
|
mod dock_area;
|
||||||
mod invalid_panel;
|
mod invalid_panel;
|
||||||
mod tab_panel;
|
mod tab_panel;
|
||||||
mod tiles;
|
|
||||||
mod window_controls;
|
mod window_controls;
|
||||||
|
|
||||||
pub use dock_area::SignedDockSkin;
|
pub use dock_area::SignedDockSkin;
|
||||||
|
|||||||
+297
-166
@@ -5,9 +5,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
|
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
||||||
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
|
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
||||||
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
Styled as _, Window, div, px, size,
|
Styled as _, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
@@ -18,17 +18,24 @@ use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
|||||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
use gpui_component::button::{Button, ButtonVariants as _};
|
||||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||||
use gpui_component::menu::DropdownMenu as _;
|
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||||
use gpui_component::{
|
use settings::{SettingsStore, TabBarSettings};
|
||||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
|
||||||
};
|
|
||||||
use signed_ui::title_bar_drag_handlers;
|
use signed_ui::title_bar_drag_handlers;
|
||||||
|
|
||||||
use crate::dock_area::SkinShared;
|
use crate::dock_area::SkinShared;
|
||||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||||
|
|
||||||
/// The drag preview's size, reported to base for the drop placeholder.
|
|
||||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||||
|
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
|
||||||
|
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
|
||||||
|
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
|
||||||
|
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
|
||||||
|
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
|
||||||
|
|
||||||
|
/// Whether a group's prev/next buttons should be drawn.
|
||||||
|
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
|
||||||
|
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
/// A panel's title, or its registered name when the panel has no handle.
|
/// A panel's title, or its registered name when the panel has no handle.
|
||||||
pub(crate) fn panel_title(
|
pub(crate) fn panel_title(
|
||||||
@@ -43,24 +50,20 @@ pub(crate) fn panel_title(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The preview that follows the cursor while a panel is dragged.
|
/// The preview that follows the cursor while a panel is dragged.
|
||||||
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
|
||||||
struct DragPanelPreview {
|
struct DragPanelPreview {
|
||||||
panel: Arc<dyn BasePanelView>,
|
panel: Arc<dyn BasePanelView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for DragPanelPreview {
|
impl Render for DragPanelPreview {
|
||||||
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 {
|
||||||
div()
|
h_flex()
|
||||||
.id("drag-panel")
|
.id("drag-panel")
|
||||||
.cursor_grab()
|
.cursor_grab()
|
||||||
.py_1()
|
.p_1()
|
||||||
.px_3()
|
|
||||||
.w_24()
|
|
||||||
.overflow_hidden()
|
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius_lg)
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
.bg(cx.theme().tokens.tab_active)
|
.bg(cx.theme().tokens.tab_active)
|
||||||
.opacity(0.75)
|
.opacity(0.75)
|
||||||
@@ -69,9 +72,14 @@ impl Render for DragPanelPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
|
||||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||||
let panel = group.active_panel()?;
|
panel_zoom_control(group.active_panel()?, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom affordance `panel` offers, if any.
|
||||||
|
///
|
||||||
|
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||||
|
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||||
panel
|
panel
|
||||||
.zoomable(cx)
|
.zoomable(cx)
|
||||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||||
@@ -79,16 +87,17 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The left-most, top-most tab group in a container.
|
/// The left-most, top-most tab group in a container.
|
||||||
|
///
|
||||||
/// A left dock's collapse button lives in this group.
|
/// 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()),
|
||||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||||
PaneRef::Tiles { .. } => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The right-most, top-most tab group.
|
/// The right-most, top-most tab group.
|
||||||
|
///
|
||||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
@@ -98,7 +107,53 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
gpui::Axis::Horizontal => children.last(),
|
gpui::Axis::Horizontal => children.last(),
|
||||||
}
|
}
|
||||||
.and_then(right_top_group),
|
.and_then(right_top_group),
|
||||||
PaneRef::Tiles { .. } => None,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bottom or right dock whose root tab group is this one, if any.
|
||||||
|
///
|
||||||
|
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||||
|
fn dock_root_group(
|
||||||
|
shared: &SkinShared,
|
||||||
|
group: &TabGroupContext,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<DockPlacement> {
|
||||||
|
let area = shared.area().upgrade()?;
|
||||||
|
let area = area.read(cx);
|
||||||
|
|
||||||
|
[DockPlacement::Bottom, DockPlacement::Right]
|
||||||
|
.into_iter()
|
||||||
|
.find(|placement| {
|
||||||
|
area.layout(*placement)
|
||||||
|
.is_some_and(|tree| tree.root().id() == group.node())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close `panel`, removing its dock instead when base would refuse an empty one.
|
||||||
|
///
|
||||||
|
/// A bottom or right dock's only panel is that case, so the whole dock goes.
|
||||||
|
fn close_panel(
|
||||||
|
shared: &SkinShared,
|
||||||
|
group: &TabGroupContext,
|
||||||
|
panel: &Arc<dyn BasePanelView>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if !panel.closable(cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dock = (group.panels().len() <= 1)
|
||||||
|
.then(|| dock_root_group(shared, group, cx))
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
match dock {
|
||||||
|
Some(placement) => {
|
||||||
|
_ = shared.area().update(cx, |area, cx| {
|
||||||
|
area.remove_dock(placement, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => group.close(panel.panel_id(cx), window, cx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,26 +199,14 @@ impl SignedTabGroupSkin {
|
|||||||
left == group.node() && group.panels().len() == 1
|
left == group.node() && group.panels().len() == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bottom or right dock whose root tab group is this one, if any.
|
|
||||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
|
||||||
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
|
||||||
let area = self.shared.area().upgrade()?;
|
|
||||||
let area = area.read(cx);
|
|
||||||
[DockPlacement::Bottom, DockPlacement::Right]
|
|
||||||
.into_iter()
|
|
||||||
.find(|placement| {
|
|
||||||
area.layout(*placement)
|
|
||||||
.is_some_and(|tree| tree.root().id() == group.node())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||||
|
///
|
||||||
/// A locked group never is, a bottom or right dock root always is.
|
/// A locked group never is, a bottom or right dock root always is.
|
||||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||||
if group.is_locked() {
|
if group.is_locked() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
|
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
group.drag_panel(ix, cx)
|
group.drag_panel(ix, cx)
|
||||||
@@ -233,15 +276,22 @@ impl SignedTabGroupSkin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The previous and next tab buttons 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 strip ends or when collapsed.
|
///
|
||||||
fn render_prev_next_tab_buttons(
|
/// Hidden by default, and only offered for crowded strips where the active
|
||||||
&self,
|
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
||||||
group: &TabGroupContext,
|
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
||||||
_cx: &mut App,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let collapsed = group.is_collapsed();
|
let collapsed = group.is_collapsed();
|
||||||
let active_ix = group.active_ix();
|
let active_ix = group.active_ix();
|
||||||
let panels_len = group.panels().len();
|
let panels_len = group.panels().len();
|
||||||
|
|
||||||
|
let hidden = SettingsStore::try_global(cx)
|
||||||
|
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
|
||||||
|
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
|
||||||
|
|
||||||
|
if !navigation_buttons_visible(hidden, panels_len) {
|
||||||
|
return Empty.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
let prev_enabled = !collapsed && active_ix > 0;
|
let prev_enabled = !collapsed && active_ix > 0;
|
||||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||||
|
|
||||||
@@ -273,100 +323,10 @@ impl SignedTabGroupSkin {
|
|||||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
.into_any_element()
|
||||||
|
|
||||||
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
|
||||||
fn render_toolbar(
|
|
||||||
&self,
|
|
||||||
group: &TabGroupContext,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
if group.is_collapsed() {
|
|
||||||
return div();
|
|
||||||
}
|
|
||||||
|
|
||||||
let zoomed = group.is_zoomed();
|
|
||||||
let handle = group.active_panel().and_then(PanelHandle::of);
|
|
||||||
let control = zoom_control(group, cx);
|
|
||||||
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
|
||||||
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
|
||||||
// A bottom or right dock's only panel cannot close through the group.
|
|
||||||
// The close item is offered, the skin removes the whole dock instead.
|
|
||||||
let closable = group.is_closable()
|
|
||||||
|| (self.is_dock_root_group(group, cx).is_some()
|
|
||||||
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
|
||||||
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
|
||||||
let panel = handle.map(|handle| handle.panel());
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.occlude()
|
|
||||||
.when_some(buttons, |this, buttons| {
|
|
||||||
this.children(
|
|
||||||
buttons
|
|
||||||
.into_iter()
|
|
||||||
.map(|button| button.small().ghost().tab_stop(false)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(|this| {
|
|
||||||
let value = if zoomed {
|
|
||||||
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
|
|
||||||
} else if toolbar_zoom {
|
|
||||||
Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In")))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((id, icon, tooltip)) = value {
|
|
||||||
this.child(
|
|
||||||
Button::new(id)
|
|
||||||
.icon(icon)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.tab_stop(false)
|
|
||||||
.tooltip_with_action(tooltip, &ToggleZoom, None)
|
|
||||||
.selected(zoomed)
|
|
||||||
.on_click({
|
|
||||||
let group = group.clone();
|
|
||||||
move |_, window, cx| group.toggle_zoom(window, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new("menu")
|
|
||||||
.icon(IconName::Ellipsis)
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.tab_stop(false)
|
|
||||||
.dropdown_menu(move |menu, window, cx| {
|
|
||||||
menu.when_some(panel.clone(), |menu, panel| {
|
|
||||||
panel.dropdown_menu(menu, window, cx)
|
|
||||||
})
|
|
||||||
.separator()
|
|
||||||
.menu_with_disabled(
|
|
||||||
if zoomed {
|
|
||||||
t("Dock.Zoom Out")
|
|
||||||
} else {
|
|
||||||
t("Dock.Zoom In")
|
|
||||||
},
|
|
||||||
Box::new(ToggleZoom),
|
|
||||||
!menu_zoom,
|
|
||||||
)
|
|
||||||
.when(closable, |menu| {
|
|
||||||
menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.anchor(Anchor::TopRight),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tab of the pill strip.
|
/// One tab of the pill strip.
|
||||||
/// While collapsed, tabs lose the active style and all interactions.
|
|
||||||
/// The strip is also how a closed bottom dock is opened again.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_tab(
|
fn render_tab(
|
||||||
&self,
|
&self,
|
||||||
@@ -382,25 +342,73 @@ impl SignedTabGroupSkin {
|
|||||||
let droppable = group.is_droppable();
|
let droppable = group.is_droppable();
|
||||||
let drag = self.tab_drag(group, ix, cx);
|
let drag = self.tab_drag(group, ix, cx);
|
||||||
let handle = PanelHandle::of(&panel);
|
let handle = PanelHandle::of(&panel);
|
||||||
|
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
||||||
|
|
||||||
|
let closable = !collapsed
|
||||||
|
&& (group.is_closable()
|
||||||
|
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
||||||
|
|
||||||
|
// Whether a double-click zooms this tab in.
|
||||||
|
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
||||||
|
|
||||||
Tab::new(ix)
|
Tab::new(ix)
|
||||||
.h_6()
|
.group(TAB_HOVER_GROUP)
|
||||||
.px_3()
|
.when(active, |this| {
|
||||||
.text_sm()
|
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||||
.whitespace_nowrap()
|
})
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
|
||||||
.gap_1()
|
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.h_7()
|
||||||
|
.min_w_40()
|
||||||
|
.gap_1()
|
||||||
|
.px_1p5()
|
||||||
|
.text_sm()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_color(cx.theme().foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_1()
|
||||||
|
.h_6()
|
||||||
|
.items_center()
|
||||||
|
.overflow_hidden()
|
||||||
|
.whitespace_nowrap()
|
||||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||||
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)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when(closable, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.when(active, |this| {
|
||||||
|
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
||||||
|
})
|
||||||
|
.when(!active, |this| {
|
||||||
|
this.invisible()
|
||||||
|
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new(id)
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip(t("Dock.Close"))
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.tab_stop(false)
|
||||||
|
.on_click({
|
||||||
|
let group = group.clone();
|
||||||
|
let shared = self.shared.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
close_panel(&shared, &group, &panel, window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
// Pill style, the selected tab is the filled pill, others show only on hover.
|
|
||||||
.styles(|styles| {
|
.styles(|styles| {
|
||||||
styles.selected(|style| {
|
styles.selected(|style| {
|
||||||
style
|
style
|
||||||
@@ -412,15 +420,15 @@ impl SignedTabGroupSkin {
|
|||||||
if active {
|
if active {
|
||||||
this
|
this
|
||||||
} else {
|
} else {
|
||||||
this.text_color(cx.theme().secondary_foreground)
|
this.text_color(cx.theme().tab_foreground)
|
||||||
.bg(cx.theme().secondary_hover)
|
.bg(cx.theme().tab)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.selected(active)
|
.selected(active)
|
||||||
.on_click({
|
.on_click({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let area = self.shared.area().clone();
|
let area = self.shared.area().clone();
|
||||||
move |_, window, cx| {
|
move |event, window, cx| {
|
||||||
group.select_tab(ix, window, cx);
|
group.select_tab(ix, window, cx);
|
||||||
|
|
||||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||||
@@ -428,7 +436,15 @@ impl SignedTabGroupSkin {
|
|||||||
_ = area.update(cx, |area, cx| {
|
_ = area.update(cx, |area, cx| {
|
||||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A double-click zooms the tab's panel
|
||||||
|
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.toggle_zoom(window, cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when(!collapsed, |this| {
|
.when(!collapsed, |this| {
|
||||||
@@ -531,10 +547,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
if group.panels().is_empty() {
|
if group.panels().is_empty() {
|
||||||
return div().id("tab-panel");
|
return div().id("tab-panel");
|
||||||
}
|
}
|
||||||
// Base refuses an empty dock, so closing its only panel removes the dock.
|
|
||||||
let dock_to_remove = (group.panels().len() <= 1)
|
|
||||||
.then(|| self.is_dock_root_group(group, cx))
|
|
||||||
.flatten();
|
|
||||||
let shared = self.shared.clone();
|
let shared = self.shared.clone();
|
||||||
|
|
||||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||||
@@ -543,14 +555,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(cx.theme().tokens.background)
|
.bg(cx.theme().tokens.background)
|
||||||
// A collapsed group has no content, so these actions are not registered.
|
|
||||||
.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| {
|
||||||
// A panel with no zoom control is not zoomed in by the keybinding.
|
|
||||||
// Zooming out is never refused.
|
|
||||||
// Otherwise a zoomed panel that lost its control would strand the user.
|
|
||||||
if !group.is_zoomed() && control.is_none() {
|
if !group.is_zoomed() && control.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -564,18 +572,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
let Some(panel) = group.active_panel() else {
|
let Some(panel) = group.active_panel() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !panel.closable(cx) {
|
close_panel(&shared, &group, panel, window, cx);
|
||||||
return;
|
|
||||||
}
|
|
||||||
let panel = panel.panel_id(cx);
|
|
||||||
match dock_to_remove {
|
|
||||||
Some(placement) => {
|
|
||||||
_ = shared.area().update(cx, |area, cx| {
|
|
||||||
area.remove_dock(placement, window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None => group.close(panel, window, cx),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -613,17 +610,23 @@ 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();
|
||||||
|
|
||||||
// On macOS the traffic lights overlay the window's top-left corner.
|
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||||
// Only the tab bar that sits under them reserves the space.
|
// tab bar under them reserves the space. That is the center's top-left group
|
||||||
// That is the center's top-left group when the left dock is closed or absent.
|
// when the left dock is closed or absent, and the zoomed group otherwise:
|
||||||
|
// a zoom fills the whole area, hiding the docks the frame would draw.
|
||||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
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);
|
||||||
|
match area.zoomed_group() {
|
||||||
|
Some(zoomed) => zoomed == group.node(),
|
||||||
|
None => {
|
||||||
!area.is_dock_open(DockPlacement::Left)
|
!area.is_dock_open(DockPlacement::Left)
|
||||||
&& area
|
&& area
|
||||||
.layout(DockPlacement::Center)
|
.layout(DockPlacement::Center)
|
||||||
.and_then(|tree| left_top_group(tree.root()))
|
.and_then(|tree| left_top_group(tree.root()))
|
||||||
== Some(group.node())
|
== Some(group.node())
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bring a newly displayed tab into view.
|
// Bring a newly displayed tab into view.
|
||||||
@@ -709,7 +712,16 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.child(
|
.child(
|
||||||
Tabs::new("tab-bar")
|
Tabs::new("tab-bar")
|
||||||
.px(px(-1.))
|
.px(px(-1.))
|
||||||
.h(TAB_BAR_HEIGHT)
|
// A collapsed group stands in for a closed bottom dock,
|
||||||
|
// which base gives only its reserved strip. The bar keeps
|
||||||
|
// its own height there rather than the title-bar height,
|
||||||
|
// so it fits instead of being clipped away.
|
||||||
|
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
||||||
|
// Only a collapsed (bottom) dock tags its bar, so the
|
||||||
|
// selector is unique when a test measures it.
|
||||||
|
.when(collapsed, |this| {
|
||||||
|
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
|
||||||
|
})
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
@@ -763,7 +775,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.and_then(PanelHandle::of)
|
.and_then(PanelHandle::of)
|
||||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||||
)
|
)
|
||||||
.child(self.render_toolbar(group, window, cx))
|
|
||||||
.children(right_dock_button),
|
.children(right_dock_button),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -833,3 +844,123 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext as _, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
|
Render, TestAppContext, Window, px,
|
||||||
|
};
|
||||||
|
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
|
||||||
|
|
||||||
|
use super::{COLLAPSED_TAB_BAR_SELECTOR, navigation_buttons_visible};
|
||||||
|
use crate::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||||
|
|
||||||
|
struct Probe {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe {
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasePanel for Probe {
|
||||||
|
fn panel_name(&self) -> &'static str {
|
||||||
|
"Probe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for Probe {
|
||||||
|
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
"Probe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for Probe {}
|
||||||
|
|
||||||
|
impl Focusable for Probe {
|
||||||
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Probe {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base reserves only `gpui_base::dock::CLOSED_BOTTOM_STRIP` for a closed
|
||||||
|
/// bottom dock, so the tab bar drawn in place of one has to shrink to that
|
||||||
|
/// height; at the full title-bar height its bottom was clipped away.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_collapsed_bottom_dock_bar_fits_the_reserved_strip(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
gpui_component::init(cx);
|
||||||
|
});
|
||||||
|
let (area, cx) = cx.add_window_view(|window, cx| {
|
||||||
|
let skin = SignedDockSkin::new(cx);
|
||||||
|
DockArea::new("test", None, window, cx).with_renderer(skin)
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
let center = cx.new(Probe::new);
|
||||||
|
let bottom = cx.new(Probe::new);
|
||||||
|
area.update(cx, |area, cx| {
|
||||||
|
area.set_center(
|
||||||
|
DockLayout::tabs().panel_view(panel_handle(center), cx),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
area.set_dock(
|
||||||
|
DockPlacement::Bottom,
|
||||||
|
DockLayout::tabs().panel_view(panel_handle(bottom), cx),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||||
|
|
||||||
|
// Open, nothing is collapsed, so no bar carries the selector.
|
||||||
|
assert!(cx.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR).is_none());
|
||||||
|
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
area.update(cx, |area, cx| {
|
||||||
|
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||||
|
|
||||||
|
let bounds = cx
|
||||||
|
.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR)
|
||||||
|
.expect("a collapsed bottom dock draws its stand-in tab bar");
|
||||||
|
// `gpui_base::dock::CLOSED_BOTTOM_STRIP`, which the engine does not
|
||||||
|
// re-export. The bar used to be `TAB_BAR_HEIGHT` here and its bottom was
|
||||||
|
// clipped away; it now has to fit inside what base reserves.
|
||||||
|
let reserved_strip = px(29.);
|
||||||
|
assert!(
|
||||||
|
bounds.size.height <= reserved_strip,
|
||||||
|
"the collapsed bar is {}px tall, more than the {}px strip base reserves",
|
||||||
|
bounds.size.height,
|
||||||
|
reserved_strip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_buttons_follow_the_setting_and_tab_count() {
|
||||||
|
// Hidden by default, no matter how many tabs there are.
|
||||||
|
assert!(!navigation_buttons_visible(true, 1));
|
||||||
|
assert!(!navigation_buttons_visible(true, 20));
|
||||||
|
|
||||||
|
// When not hidden they still only appear for a crowded strip.
|
||||||
|
assert!(!navigation_buttons_visible(false, 10));
|
||||||
|
assert!(navigation_buttons_visible(false, 11));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Div, DragMoveEvent, Empty, InteractiveElement as _,
|
|
||||||
IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
|
||||||
Size, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_base::dock::{
|
|
||||||
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
|
|
||||||
};
|
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
|
||||||
use gpui_component::dock::PanelHandle;
|
|
||||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
|
||||||
use gpui_component::scroll::Scrollbar;
|
|
||||||
use gpui_component::{
|
|
||||||
ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::dock_area::SkinShared;
|
|
||||||
use crate::t;
|
|
||||||
use crate::tab_panel::panel_title;
|
|
||||||
|
|
||||||
/// How far a resize handle sticks out past the tile's edge.
|
|
||||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
|
||||||
|
|
||||||
/// The payload a tile drag carries, so one canvas ignores another's drags.
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct DragMoving(NodeId);
|
|
||||||
|
|
||||||
impl Render for DragMoving {
|
|
||||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
Empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The payload a tile resize carries, for the same reason.
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct DragResizing(NodeId);
|
|
||||||
|
|
||||||
impl Render for DragResizing {
|
|
||||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
Empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One tiles canvas's appearance.
|
|
||||||
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
|
|
||||||
pub(crate) struct SignedTilesSkin {
|
|
||||||
shared: Rc<SkinShared>,
|
|
||||||
scroll_handle: ScrollHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SignedTilesSkin {
|
|
||||||
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
|
|
||||||
Self {
|
|
||||||
shared,
|
|
||||||
scroll_handle: ScrollHandle::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resize_handle(
|
|
||||||
&self,
|
|
||||||
tile: &TileContext,
|
|
||||||
id: &'static str,
|
|
||||||
side: ResizeSide,
|
|
||||||
build: impl FnOnce(Stateful<Div>) -> Stateful<Div>,
|
|
||||||
) -> Stateful<Div> {
|
|
||||||
let node = tile.node();
|
|
||||||
|
|
||||||
build(div().id(id).absolute())
|
|
||||||
.on_mouse_down(MouseButton::Left, {
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |event: &MouseDownEvent, window, cx| {
|
|
||||||
tile.begin_resize(side, event.position, window, cx);
|
|
||||||
cx.stop_propagation();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_drag(DragResizing(node), |drag, _, _, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
cx.new(|_| drag.clone())
|
|
||||||
})
|
|
||||||
.on_drag_move({
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |event: &DragMoveEvent<DragResizing>, window, cx| {
|
|
||||||
if event.drag(cx).0 != node {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tile.resize_to(event.event.position, window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
|
|
||||||
/// They use click handlers, the zoom and close actions target a focused tab group.
|
|
||||||
fn render_tile_controls(
|
|
||||||
&self,
|
|
||||||
tile: &TileContext,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let handle = PanelHandle::of(tile.panel());
|
|
||||||
let control = handle.and_then(|handle| handle.zoom_control(cx));
|
|
||||||
let zoomed = tile.is_zoomed();
|
|
||||||
let toolbar_zoom =
|
|
||||||
tile.is_zoomable() && control.is_some_and(|control| control.toolbar_visible());
|
|
||||||
let menu_zoom = tile.is_zoomable() && control.is_some_and(|control| control.menu_visible());
|
|
||||||
let closable = tile.is_closable();
|
|
||||||
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
|
||||||
let panel = handle.map(|handle| handle.panel());
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.occlude()
|
|
||||||
.when_some(buttons, |this, buttons| {
|
|
||||||
this.children(
|
|
||||||
buttons
|
|
||||||
.into_iter()
|
|
||||||
.map(|button| button.xsmall().ghost().tab_stop(false)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(
|
|
||||||
match (zoomed, toolbar_zoom) {
|
|
||||||
(true, _) => Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))),
|
|
||||||
(false, true) => Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))),
|
|
||||||
(false, false) => None,
|
|
||||||
},
|
|
||||||
|this, (id, icon, tooltip)| {
|
|
||||||
this.child(
|
|
||||||
Button::new(id)
|
|
||||||
.icon(icon)
|
|
||||||
.xsmall()
|
|
||||||
.ghost()
|
|
||||||
.tab_stop(false)
|
|
||||||
.tooltip(tooltip)
|
|
||||||
.selected(zoomed)
|
|
||||||
.on_click({
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |_, window, cx| tile.toggle_zoom(window, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("menu")
|
|
||||||
.icon(IconName::Ellipsis)
|
|
||||||
.xsmall()
|
|
||||||
.ghost()
|
|
||||||
.tab_stop(false)
|
|
||||||
.dropdown_menu({
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |menu, window, cx| {
|
|
||||||
menu.when_some(panel.clone(), |menu, panel| {
|
|
||||||
panel.dropdown_menu(menu, window, cx)
|
|
||||||
})
|
|
||||||
.separator()
|
|
||||||
.item(
|
|
||||||
PopupMenuItem::new(match zoomed {
|
|
||||||
true => t("Dock.Zoom Out"),
|
|
||||||
false => t("Dock.Zoom In"),
|
|
||||||
})
|
|
||||||
.disabled(!menu_zoom && !zoomed)
|
|
||||||
.on_click({
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |_, window, cx| tile.toggle_zoom(window, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when(closable, |menu| {
|
|
||||||
menu.separator()
|
|
||||||
.item(PopupMenuItem::new(t("Dock.Close")).on_click({
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |_, window, cx| tile.close(window, cx)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.anchor(gpui::Anchor::TopRight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TilesRenderer for SignedTilesSkin {
|
|
||||||
fn frame(&self, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
|
||||||
div()
|
|
||||||
.id("tiles")
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.bg(cx.theme().tokens.tiles)
|
|
||||||
.track_scroll(&self.scroll_handle)
|
|
||||||
.overflow_scroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tile_frame(&self, tile: &TileContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
|
||||||
v_flex()
|
|
||||||
.id(("tile", tile.panel_id().as_u64()))
|
|
||||||
.occlude()
|
|
||||||
.overflow_hidden()
|
|
||||||
.bg(cx.theme().tokens.background)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.rounded(cx.theme().tile_radius)
|
|
||||||
// Room for the title bar, which overlays the top padding.
|
|
||||||
// Base draws the panel as a plain child, this keeps them apart.
|
|
||||||
.pt(DRAG_BAR_HEIGHT)
|
|
||||||
// Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
|
|
||||||
.when(tile.is_zoomed(), |this| this.size_full())
|
|
||||||
.on_mouse_down(MouseButton::Left, {
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |_, window, cx| tile.bring_to_front(window, cx)
|
|
||||||
})
|
|
||||||
// A gesture can end anywhere, so both mouse-up hooks run.
|
|
||||||
// Each is a no-op unless this tile is the one that moved.
|
|
||||||
.on_mouse_up(MouseButton::Left, {
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
tile.end_move(window, cx);
|
|
||||||
tile.end_resize(window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_mouse_up_out(MouseButton::Left, {
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
tile.end_move(window, cx);
|
|
||||||
tile.end_resize(window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement {
|
|
||||||
let node = tile.node();
|
|
||||||
let handle = PanelHandle::of(tile.panel());
|
|
||||||
let title_style = handle.and_then(|handle| handle.title_style(cx));
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id("drag-bar")
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.w_full()
|
|
||||||
.h(DRAG_BAR_HEIGHT)
|
|
||||||
.items_center()
|
|
||||||
.gap_1()
|
|
||||||
.pl_3()
|
|
||||||
.pr_2()
|
|
||||||
.when_some(title_style, |this, style| {
|
|
||||||
this.bg(style.background).text_color(style.foreground)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_16()
|
|
||||||
.overflow_hidden()
|
|
||||||
.text_ellipsis()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(panel_title(tile.panel(), window, cx)),
|
|
||||||
)
|
|
||||||
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
|
|
||||||
.child(self.render_tile_controls(tile, window, cx))
|
|
||||||
// A zoomed tile is not at its stored bounds, so moving it would mean nothing.
|
|
||||||
.when(!tile.is_zoomed(), |this| {
|
|
||||||
this.cursor_grab()
|
|
||||||
.on_mouse_down(MouseButton::Left, {
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |event: &MouseDownEvent, window, cx| {
|
|
||||||
tile.begin_move(event.position, window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_drag(DragMoving(node), |drag, _, _, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
cx.new(|_| drag.clone())
|
|
||||||
})
|
|
||||||
.on_drag_move({
|
|
||||||
let tile = tile.clone();
|
|
||||||
move |event: &DragMoveEvent<DragMoving>, window, cx| {
|
|
||||||
if event.drag(cx).0 != node {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tile.move_to(event.event.position, window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_resize_handles(
|
|
||||||
&self,
|
|
||||||
tile: &TileContext,
|
|
||||||
_: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> AnyElement {
|
|
||||||
let bounds = tile.bounds();
|
|
||||||
|
|
||||||
// A passive full-tile box, so handles sit against the tile, not its flow neighbours.
|
|
||||||
// It registers no interaction, so it does not shadow the panel underneath.
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
self.resize_handle(tile, "left-resize-handle", ResizeSide::Left, |this| {
|
|
||||||
this.cursor_ew_resize()
|
|
||||||
.top_0()
|
|
||||||
.left(HANDLE_OFFSET)
|
|
||||||
.w(HANDLE_SIZE)
|
|
||||||
.h(bounds.size.height)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
self.resize_handle(tile, "right-resize-handle", ResizeSide::Right, |this| {
|
|
||||||
this.cursor_ew_resize()
|
|
||||||
.top_0()
|
|
||||||
.right(HANDLE_OFFSET)
|
|
||||||
.w(HANDLE_SIZE)
|
|
||||||
.h(bounds.size.height)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
self.resize_handle(tile, "top-resize-handle", ResizeSide::Top, |this| {
|
|
||||||
this.cursor_ns_resize()
|
|
||||||
.left_0()
|
|
||||||
.top(HANDLE_OFFSET)
|
|
||||||
.w(bounds.size.width)
|
|
||||||
.h(HANDLE_SIZE)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
self.resize_handle(tile, "bottom-resize-handle", ResizeSide::Bottom, |this| {
|
|
||||||
this.cursor_ns_resize()
|
|
||||||
.left_0()
|
|
||||||
.bottom(HANDLE_OFFSET)
|
|
||||||
.w(bounds.size.width)
|
|
||||||
.h(HANDLE_SIZE)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Icon::new(IconName::ResizeCorner)
|
|
||||||
.size_3()
|
|
||||||
.absolute()
|
|
||||||
.right(px(1.))
|
|
||||||
.bottom(px(1.))
|
|
||||||
.text_color(cx.theme().muted_foreground.opacity(0.5)),
|
|
||||||
)
|
|
||||||
.child(self.resize_handle(
|
|
||||||
tile,
|
|
||||||
"corner-resize-handle",
|
|
||||||
ResizeSide::BottomRight,
|
|
||||||
|this| {
|
|
||||||
this.cursor_nwse_resize()
|
|
||||||
.right(HANDLE_OFFSET)
|
|
||||||
.bottom(HANDLE_OFFSET)
|
|
||||||
.size_3()
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
|
|
||||||
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
|
||||||
h_flex()
|
|
||||||
.id(("tile-panel", tile.panel_id().as_u64()))
|
|
||||||
.overflow_hidden()
|
|
||||||
.size_full()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The canvas scrollbar, as an overlay.
|
|
||||||
/// Placed inside the frame it would end up underneath every tile.
|
|
||||||
fn render_overlay(
|
|
||||||
&self,
|
|
||||||
content: Size<Pixels>,
|
|
||||||
_: &mut Window,
|
|
||||||
_: &mut App,
|
|
||||||
) -> Option<AnyElement> {
|
|
||||||
Some(
|
|
||||||
Scrollbar::new(&self.scroll_handle)
|
|
||||||
.scroll_size(content)
|
|
||||||
.when_some(self.shared.tiles_scrollbar_mode(), |this, mode| {
|
|
||||||
this.mode(mode)
|
|
||||||
})
|
|
||||||
.into_any_element(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn grid_size(&self, cx: &App) -> Pixels {
|
|
||||||
cx.theme().tile_grid_size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,16 @@ pub enum AppearanceMode {
|
|||||||
Dark,
|
Dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EventFetchingStrategy {
|
||||||
|
/// Only the relays declared in the repository announcement.
|
||||||
|
Curated,
|
||||||
|
/// Repository relays plus every maintainer's NIP-65 relays.
|
||||||
|
#[default]
|
||||||
|
Uncensored,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -54,6 +64,22 @@ impl Default for ThemeSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tab bar chrome.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct TabBarSettings {
|
||||||
|
/// Hide the previous and next tab buttons.
|
||||||
|
pub hide_navigation_buttons: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TabBarSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hide_navigation_buttons: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct GraspServersSettings {
|
pub struct GraspServersSettings {
|
||||||
@@ -125,7 +151,9 @@ pub struct CreateRepositorySettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub appearance: AppearanceMode,
|
pub appearance: AppearanceMode,
|
||||||
|
pub event_fetching: EventFetchingStrategy,
|
||||||
pub theme: ThemeSettings,
|
pub theme: ThemeSettings,
|
||||||
|
pub tab_bar: TabBarSettings,
|
||||||
pub grasp_servers: GraspServersSettings,
|
pub grasp_servers: GraspServersSettings,
|
||||||
pub local_repos: LocalReposSettings,
|
pub local_repos: LocalReposSettings,
|
||||||
pub checkouts: CheckoutsSettings,
|
pub checkouts: CheckoutsSettings,
|
||||||
@@ -140,6 +168,7 @@ mod tests {
|
|||||||
fn json_roundtrip_preserves_everything() {
|
fn json_roundtrip_preserves_everything() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
appearance: AppearanceMode::Dark,
|
appearance: AppearanceMode::Dark,
|
||||||
|
event_fetching: EventFetchingStrategy::Curated,
|
||||||
theme: ThemeSettings {
|
theme: ThemeSettings {
|
||||||
radius: 8.0,
|
radius: 8.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -161,9 +190,19 @@ mod tests {
|
|||||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||||
assert_eq!(settings.theme.radius, 4.0);
|
assert_eq!(settings.theme.radius, 4.0);
|
||||||
|
// Unset fields fall back to their defaults, including the fetching strategy.
|
||||||
|
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
|
||||||
// The rest of the theme and the other groups keep their defaults.
|
// The rest of the theme and the other groups keep their defaults.
|
||||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||||
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||||
assert_eq!(settings.create_repository.default_folder, None);
|
assert_eq!(settings.create_repository.default_folder, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_fetching_uses_snake_case() {
|
||||||
|
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
|
||||||
|
assert_eq!(json, r#""curated""#);
|
||||||
|
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
|
||||||
|
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ impl SettingsStore {
|
|||||||
cx.global::<GlobalSettingsStore>().0.clone()
|
cx.global::<GlobalSettingsStore>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieve the global settings store if one has been installed.
|
||||||
|
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||||
|
cx.try_global::<GlobalSettingsStore>()
|
||||||
|
.map(|store| store.0.clone())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalSettingsStore(entity));
|
cx.set_global(GlobalSettingsStore(entity));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
|
||||||
///
|
|
||||||
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
|
||||||
/// not part of the NIP-34 draft, read support for interop.
|
|
||||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
|
||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
@@ -18,19 +18,18 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPullRequest,
|
Kind::GitPullRequest,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
Kind::GitPullRequestUpdate,
|
Kind::GitPullRequestUpdate,
|
||||||
COVER_NOTE_KIND,
|
|
||||||
Kind::GitStatusOpen,
|
Kind::GitStatusOpen,
|
||||||
Kind::GitStatusApplied,
|
Kind::GitStatusApplied,
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Git root kinds that make a comment or cover note count as git activity.
|
/// Git root kinds that make a comment count as git activity.
|
||||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -38,6 +37,15 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
|||||||
Kind::GitRepoAnnouncement,
|
Kind::GitRepoAnnouncement,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
||||||
|
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||||
|
kind == Kind::GitRepoAnnouncement
|
||||||
|
|| kind == Kind::RepoState
|
||||||
|
|| kind == Kind::EventDeletion
|
||||||
|
|| kind == Kind::RequestToVanish
|
||||||
|
|| ACTIVITY_KINDS.contains(&kind)
|
||||||
|
}
|
||||||
|
|
||||||
/// Value of the first tag named `name` on `event`.
|
/// Value of the first tag named `name` on `event`.
|
||||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||||
event
|
event
|
||||||
@@ -140,13 +148,7 @@ pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
|||||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||||
/// through [`is_git_activity`] before display.
|
/// through [`is_git_activity`] before display.
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||||
.kinds(
|
|
||||||
ACTIVITY_KINDS
|
|
||||||
.into_iter()
|
|
||||||
.chain(std::iter::once(COVER_NOTE_KIND)),
|
|
||||||
)
|
|
||||||
.author(me)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||||
@@ -155,12 +157,6 @@ fn is_git_comment(event: &Event) -> bool {
|
|||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
|
||||||
fn is_git_cover_note(event: &Event) -> bool {
|
|
||||||
event.kind == COVER_NOTE_KIND
|
|
||||||
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a status event references a git root, checked via its `k` tag.
|
/// Whether a status event references a git root, checked via its `k` tag.
|
||||||
fn is_git_status(event: &Event) -> bool {
|
fn is_git_status(event: &Event) -> bool {
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
@@ -175,7 +171,7 @@ pub fn is_git_activity(event: &Event) -> bool {
|
|||||||
| Kind::GitStatusApplied
|
| Kind::GitStatusApplied
|
||||||
| Kind::GitStatusClosed
|
| Kind::GitStatusClosed
|
||||||
| Kind::GitStatusDraft => is_git_status(event),
|
| Kind::GitStatusDraft => is_git_status(event),
|
||||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +180,11 @@ pub fn all_announcements() -> Filter {
|
|||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All repository state events, carrying each repository's refs and last push time.
|
||||||
|
pub fn all_states() -> Filter {
|
||||||
|
Filter::new().kind(Kind::RepoState)
|
||||||
|
}
|
||||||
|
|
||||||
/// How far back deletion requests are fetched and stored.
|
/// How far back deletion requests are fetched and stored.
|
||||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
|
|
||||||
@@ -262,17 +263,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
fn status_activity_depends_on_the_lowercase_k_tag() {
|
||||||
let status = signed(
|
let status = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||||
);
|
);
|
||||||
let cover = signed(
|
|
||||||
&keys(1),
|
|
||||||
COVER_NOTE_KIND,
|
|
||||||
vec![kind_tag("k", Kind::GitPatch)],
|
|
||||||
);
|
|
||||||
let unrelated = signed(
|
let unrelated = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
@@ -280,7 +276,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_git_activity(&status));
|
assert!(is_git_activity(&status));
|
||||||
assert!(is_git_activity(&cover));
|
|
||||||
assert!(!is_git_activity(&unrelated));
|
assert!(!is_git_activity(&unrelated));
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!is_git_activity(&signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
use crate::{RepoAddr, activity_subject};
|
||||||
|
|
||||||
/// Window before `now` that an advanced cutoff retreats to.
|
/// Window before `now` that an advanced cutoff retreats to.
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||||
@@ -121,14 +121,11 @@ impl InboxItem {
|
|||||||
/// - patch (1617): its `e` parent patch, else itself
|
/// - patch (1617): its `e` parent patch, else itself
|
||||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
/// - PR update (1619): uppercase `E`
|
/// - PR update (1619): uppercase `E`
|
||||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
/// - statuses (1630-1633): NIP-10 root `e`
|
||||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||||
where
|
where
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event>,
|
||||||
{
|
{
|
||||||
if event.kind == COVER_NOTE_KIND {
|
|
||||||
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
|
||||||
}
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
Kind::GitPatch => Some(match first_e_id(event) {
|
Kind::GitPatch => Some(match first_e_id(event) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
pub mod annotations;
|
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
@@ -8,7 +7,6 @@ pub mod state;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||||
pub use annotations::COVER_NOTE_KIND;
|
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use filters::{
|
pub use filters::{
|
||||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||||
|
|||||||
+155
-134
@@ -21,15 +21,8 @@ use crate::repos::RepoListStore;
|
|||||||
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;
|
||||||
|
|
||||||
/// Relays connected at startup, before any user-specific relay config is known.
|
/// Relays connected at startup, before any user-specific relay config is known.
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://index.ngit.dev",
|
|
||||||
"wss://profiles.nostr1.com",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Relays used to index the user's NIP-65 relay list.
|
/// 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"];
|
||||||
|
|
||||||
@@ -42,20 +35,15 @@ pub enum BackendEvent {
|
|||||||
PassphraseRequired,
|
PassphraseRequired,
|
||||||
/// The signer changed on login, logout or account switch.
|
/// The signer changed on login, logout or account switch.
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
/// New events were received from a relay and stored in the database.
|
/// Kind-0 metadata arrived for these authors; re-read them from the store.
|
||||||
///
|
ProfileUpdates(Vec<PublicKey>),
|
||||||
/// Batched: [`Backend`]'s notification pump coalesces everything a
|
/// Repository events arrived: announcements, states, activity and deletions.
|
||||||
/// relay delivers within one debounce window into a single event,
|
RepoUpdates(Vec<Update>),
|
||||||
/// instead of emitting per-event and making every subscriber debounce
|
|
||||||
/// the same burst independently.
|
|
||||||
NostrUpdate(Vec<Update>),
|
|
||||||
Synced,
|
Synced,
|
||||||
SyncProgress {
|
SyncProgress {
|
||||||
total: u64,
|
total: u64,
|
||||||
current: u64,
|
current: u64,
|
||||||
},
|
},
|
||||||
/// An event built locally was signed, broadcast and stored.
|
|
||||||
Published(Box<Event>),
|
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +88,16 @@ impl Backend {
|
|||||||
|
|
||||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let mut notifications = pump_client.notifications();
|
let mut notifications = pump_client.notifications();
|
||||||
let mut pending: Vec<Update> = Vec::new();
|
let mut pending_profiles: HashSet<PublicKey> = HashSet::new();
|
||||||
|
let mut pending_repos: Vec<Update> = Vec::new();
|
||||||
|
let mut seen: HashSet<EventId> = HashSet::new();
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
match notifications.next().await {
|
match next_update(&mut notifications, &mut seen).await {
|
||||||
Some(ClientNotification::Event { event, .. }) => {
|
Some(UpdateEvent::Profile(author)) => {
|
||||||
pending.push(Update::from_event(&event));
|
pending_profiles.insert(author);
|
||||||
}
|
}
|
||||||
Some(_) => continue,
|
Some(UpdateEvent::Repo(update)) => pending_repos.push(update),
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,28 +113,33 @@ impl Backend {
|
|||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let next = notifications.next();
|
let next = next_update(&mut notifications, &mut seen);
|
||||||
futures::pin_mut!(next);
|
futures::pin_mut!(next);
|
||||||
|
|
||||||
match futures::future::select(next, timer).await {
|
match futures::future::select(next, timer).await {
|
||||||
futures::future::Either::Left((
|
futures::future::Either::Left((Some(UpdateEvent::Profile(author)), _)) => {
|
||||||
Some(ClientNotification::Event { event, .. }),
|
pending_profiles.insert(author);
|
||||||
_,
|
}
|
||||||
)) => {
|
futures::future::Either::Left((Some(UpdateEvent::Repo(update)), _)) => {
|
||||||
pending.push(Update::from_event(&event));
|
pending_repos.push(update);
|
||||||
}
|
}
|
||||||
futures::future::Either::Left((Some(_), _)) => continue,
|
|
||||||
futures::future::Either::Left((None, _)) => break 'outer,
|
futures::future::Either::Left((None, _)) => break 'outer,
|
||||||
futures::future::Either::Right(_) => break,
|
futures::future::Either::Right(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let batch = std::mem::take(&mut pending);
|
let profiles: Vec<PublicKey> = pending_profiles.drain().collect();
|
||||||
|
let repos = std::mem::take(&mut pending_repos);
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) = this.update(cx, |_this, cx| {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
|
if !profiles.is_empty() {
|
||||||
{
|
cx.emit(BackendEvent::ProfileUpdates(profiles));
|
||||||
log::warn!("failed to emit nostr update: {e}");
|
}
|
||||||
|
if !repos.is_empty() {
|
||||||
|
cx.emit(BackendEvent::RepoUpdates(repos));
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to emit backend update: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +149,10 @@ impl Backend {
|
|||||||
pump.detach();
|
pump.detach();
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
weak.update(cx, |this, cx| {
|
||||||
log::warn!("backend dropped before bootstrap could run: {error}");
|
this.restore_session(cx);
|
||||||
}
|
})
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -170,42 +166,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let client = self.client.clone();
|
|
||||||
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
for url in BOOTSTRAP_RELAYS {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
|
||||||
client
|
|
||||||
.add_relay(url)
|
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.connect().await;
|
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(()) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.restore_session(cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
});
|
|
||||||
notify_task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore the saved session from the Keyring.
|
/// Restore the saved session from the Keyring.
|
||||||
///
|
///
|
||||||
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||||
@@ -230,7 +190,9 @@ impl Backend {
|
|||||||
let result = async {
|
let result = async {
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
} else if content.starts_with("bunker://") {
|
} else if content.starts_with("bunker://") {
|
||||||
let (base, keys) = extract_master_key(&content);
|
let (base, keys) = extract_master_key(&content);
|
||||||
let uri = NostrConnectUri::parse(base)?;
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
@@ -241,14 +203,19 @@ impl Backend {
|
|||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
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") {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
cx.emit(BackendEvent::PassphraseRequired);
|
cx.emit(BackendEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -446,9 +413,6 @@ impl Backend {
|
|||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
// Initialize directly at the user's chosen destination.
|
// Initialize directly at the user's chosen destination.
|
||||||
// No mirror is pre-populated: `GitCache::ensure_clone` lazily clones
|
|
||||||
// from the grasp server the first time the repo detail view needs it,
|
|
||||||
// exactly like every other repository.
|
|
||||||
let destination = {
|
let 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() {
|
||||||
@@ -513,9 +477,7 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
require_relay_accepted(output, event)?
|
||||||
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
|
||||||
event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The state event is the push authorization. Stage it on each
|
// The state event is the push authorization. Stage it on each
|
||||||
@@ -570,15 +532,11 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it
|
// Staging already stored the event locally, publishing makes it
|
||||||
// visible to the other relays and clients.
|
// visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
|
{
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.announce_published(state_event.clone(), cx)
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event)
|
let announcement = Announcement::from_event(&event)
|
||||||
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
||||||
@@ -666,9 +624,7 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
require_relay_accepted(output, event)?
|
||||||
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
|
||||||
event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let refs = state.refs.clone();
|
let refs = state.refs.clone();
|
||||||
@@ -725,13 +681,11 @@ impl Backend {
|
|||||||
|
|
||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
|
{
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx))
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point `origin` at the first grasp server so later pushes have a target.
|
// Point `origin` at the first grasp server so later pushes have a target.
|
||||||
@@ -907,15 +861,11 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing notifies
|
// Staging already stored the event locally, publishing notifies
|
||||||
// the repository views and other relays and clients.
|
// the repository views and other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
|
{
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.announce_published(state_event.clone(), cx)
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
})
|
})
|
||||||
@@ -1086,7 +1036,7 @@ impl Backend {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for url in user_grasp_list_servers(client.clone(), public_key).await? {
|
for url in user_grasp_list_servers(&client, public_key).await? {
|
||||||
client.add_relay(url).and_connect().await.ok();
|
client.add_relay(url).and_connect().await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,6 +1155,21 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync filters through the SDK's NIP-65 gossip targeting.
|
||||||
|
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |_this, _cx| {
|
||||||
|
for filter in filters {
|
||||||
|
if let Err(e) = client.sync(filter).await {
|
||||||
|
log::warn!("gossip relay fetch failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1222,14 +1187,15 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
/// Sync several bootstrap filters in order, within a single task.
|
||||||
|
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let (tx, mut rx) = SyncProgress::channel();
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
|
||||||
self.sync_progress = Some((0, 0));
|
self.sync_progress = Some((0, 0));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let mut last_percent: u64 = 0;
|
let mut last_percent: u64 = 0;
|
||||||
|
|
||||||
while rx.changed().await.is_ok() {
|
while rx.changed().await.is_ok() {
|
||||||
@@ -1254,23 +1220,29 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok::<(), anyhow::Error>(())
|
||||||
});
|
})
|
||||||
progress_task.detach();
|
.detach();
|
||||||
|
|
||||||
let sync = cx.background_spawn(async move {
|
let sync = cx.background_spawn(async move {
|
||||||
let opts = SyncOptions::default().progress(tx);
|
let mut first_error = None;
|
||||||
sync_bootstrap_only(&client, filter, opts).await
|
|
||||||
|
for filter in filters {
|
||||||
|
let opts = SyncOptions::default().progress(tx.clone());
|
||||||
|
if let Err(error) = sync_bootstrap_only(&client, filter, opts).await {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match first_error {
|
||||||
|
Some(error) => Err(error),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
match sync.await {
|
match sync.await {
|
||||||
Ok(summary) => {
|
Ok(_) => {
|
||||||
log::debug!(
|
|
||||||
"sync done: {} received, {} sent",
|
|
||||||
summary.received.len(),
|
|
||||||
summary.sent.len()
|
|
||||||
);
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.sync_progress = None;
|
this.sync_progress = None;
|
||||||
cx.emit(BackendEvent::Synced);
|
cx.emit(BackendEvent::Synced);
|
||||||
@@ -1284,17 +1256,10 @@ impl Backend {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
|
Ok::<(), anyhow::Error>(())
|
||||||
///
|
})
|
||||||
/// Callers publish with `client.send_event(...)` directly, then call this
|
.detach();
|
||||||
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
|
||||||
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
|
||||||
cx.emit(BackendEvent::Published(Box::new(event)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
||||||
@@ -1319,6 +1284,42 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A relay event the backend routes to a store group.
|
||||||
|
enum UpdateEvent {
|
||||||
|
Profile(PublicKey),
|
||||||
|
Repo(Update),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await the next relay event from the notification stream.
|
||||||
|
async fn next_update(
|
||||||
|
notifications: &mut (impl futures::Stream<Item = ClientNotification> + Unpin),
|
||||||
|
seen: &mut HashSet<EventId>,
|
||||||
|
) -> Option<UpdateEvent> {
|
||||||
|
loop {
|
||||||
|
match notifications.next().await {
|
||||||
|
Some(ClientNotification::Message { message, .. }) => {
|
||||||
|
let RelayMessage::Event { event, .. } = *message else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = match event.kind {
|
||||||
|
Kind::Metadata => UpdateEvent::Profile(event.pubkey),
|
||||||
|
kind if filters::is_repo_kind(kind) => {
|
||||||
|
UpdateEvent::Repo(Update::from_event(&event))
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if seen.insert(event.id) {
|
||||||
|
return Some(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign and send a single NIP-09 deletion request for `event`.
|
/// Sign and send a single NIP-09 deletion request for `event`.
|
||||||
async fn retract_event(
|
async fn retract_event(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -1328,16 +1329,14 @@ async fn retract_event(
|
|||||||
let builder = EventDeletionRequest::new()
|
let builder = EventDeletionRequest::new()
|
||||||
.id(event.id)
|
.id(event.id)
|
||||||
.into_event_builder();
|
.into_event_builder();
|
||||||
|
|
||||||
let deletion = builder.finalize_async(signer).await?;
|
let deletion = builder.finalize_async(signer).await?;
|
||||||
client.send_event(&deletion).broadcast().await?;
|
client.send_event(&deletion).broadcast().await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
||||||
///
|
|
||||||
/// The SDK does not treat "accepted by zero relays" as an error on its own:
|
|
||||||
/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`.
|
|
||||||
/// This turns that case into an error the caller can surface.
|
|
||||||
pub(crate) fn require_relay_accepted(
|
pub(crate) fn require_relay_accepted(
|
||||||
output: SendEventOutput,
|
output: SendEventOutput,
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -1395,10 +1394,29 @@ async fn connect_repo_relays(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add and connect the startup relays.
|
||||||
|
async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
|
||||||
|
for url in BOOTSTRAP_RELAYS {
|
||||||
|
client.add_relay(url).and_connect().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for url in INDEXER_RELAYS {
|
||||||
|
client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
|
.and_connect()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn subscribe_bootstrap_only(
|
pub(crate) async fn subscribe_bootstrap_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(Duration::from_secs(10)));
|
.timeout(Some(Duration::from_secs(10)));
|
||||||
@@ -1418,11 +1436,14 @@ pub(crate) async fn sync_bootstrap_only(
|
|||||||
filter: Filter,
|
filter: Filter,
|
||||||
opts: SyncOptions,
|
opts: SyncOptions,
|
||||||
) -> Result<SyncSummary, Error> {
|
) -> Result<SyncSummary, Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
let output = client
|
let output = client
|
||||||
.sync(filter)
|
.sync(filter)
|
||||||
.with(BOOTSTRAP_RELAYS)
|
.with(BOOTSTRAP_RELAYS)
|
||||||
.opts(opts)
|
.opts(opts)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(output.value)
|
Ok(output.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1495,7 +1516,7 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_grasp_list_servers(
|
pub async fn user_grasp_list_servers(
|
||||||
client: Client,
|
client: &Client,
|
||||||
user: PublicKey,
|
user: PublicKey,
|
||||||
) -> Result<Vec<RelayUrl>, Error> {
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
let events: Vec<Event> = client
|
let events: Vec<Event> = client
|
||||||
|
|||||||
@@ -17,15 +17,9 @@ use crate::repos::RepoListStore;
|
|||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// How often the statuses are recomputed against the local refs.
|
/// How often the statuses are recomputed against the local refs.
|
||||||
///
|
|
||||||
/// A commit lands in a checkout long before the remote reconciliation cadence,
|
|
||||||
/// so this fast pass surfaces ready-to-push and ready-to-contribute checkouts
|
|
||||||
/// within a second or two. It reads the tracking refs only, no network.
|
|
||||||
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// How often a full pass refreshes the remotes while any repository panel is open.
|
/// How often a full pass refreshes the remotes while any repository panel is open.
|
||||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
||||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
@@ -46,10 +40,6 @@ pub struct CheckoutStatus {
|
|||||||
/// 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.
|
/// What the branch is compared against.
|
||||||
/// For ready-to-contribute statuses, the announced HEAD branch.
|
|
||||||
/// The fallbacks are `main`, then the first local branch.
|
|
||||||
/// For ready-to-push statuses, the remote-tracking ref.
|
|
||||||
/// Unpushed commits are counted against it.
|
|
||||||
///
|
///
|
||||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||||
pub base: String,
|
pub base: String,
|
||||||
@@ -67,11 +57,6 @@ struct Remembered {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Global store of local-checkout associations and per-checkout statuses.
|
/// Global store of local-checkout associations and per-checkout statuses.
|
||||||
///
|
|
||||||
/// Readers (the sidebar rows, the repository panels) observe this store and
|
|
||||||
/// derive what they display from their own snapshots, so publishing needs no
|
|
||||||
/// fine-grained entities: the store notifies when a slice changed and each
|
|
||||||
/// reader re-derives only what it shows.
|
|
||||||
pub struct CheckoutsStore {
|
pub struct CheckoutsStore {
|
||||||
/// Checkout paths per announced repository.
|
/// Checkout paths per announced repository.
|
||||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
@@ -144,6 +129,7 @@ impl CheckoutsStore {
|
|||||||
this.statuses.clear();
|
this.statuses.clear();
|
||||||
this.push_statuses.clear();
|
this.push_statuses.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refresh(cx);
|
this.refresh(cx);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -282,9 +268,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolve the associations and the requested statuses.
|
/// Re-resolve the associations and the requested statuses.
|
||||||
///
|
|
||||||
/// Requests arriving while a pass runs fold into a follow-up, requests
|
|
||||||
/// arriving while the debounce timer is pending are dropped.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
||||||
return;
|
return;
|
||||||
@@ -300,12 +283,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One full resolve and apply cycle, the debounced entry point.
|
/// One full resolve and apply cycle, the debounced entry point.
|
||||||
///
|
|
||||||
/// Re-resolves the associations from the settings, the scan and the
|
|
||||||
/// announcements, then recomputes the requested statuses against freshly
|
|
||||||
/// fetched remotes. Full passes run on every input change and on the
|
|
||||||
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
|
|
||||||
/// the fast local pass.
|
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.debounce_pending = false;
|
self.debounce_pending = false;
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
@@ -431,10 +408,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule the fast local status pass, unless one is already pending.
|
/// Schedule the fast local status pass, unless one is already pending.
|
||||||
///
|
|
||||||
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
|
|
||||||
/// the local refs, with no network, so a new commit in a checkout surfaces in
|
|
||||||
/// a second or two instead of at the next remote reconciliation.
|
|
||||||
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.local_pending {
|
if self.local_pending {
|
||||||
return;
|
return;
|
||||||
@@ -452,10 +425,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The fast local status pass.
|
/// The fast local status pass.
|
||||||
///
|
|
||||||
/// Recomputes the statuses against the local refs; when the remote
|
|
||||||
/// reconciliation cadence elapsed, it runs a full pass instead so pushes
|
|
||||||
/// made elsewhere do not linger as `to push`.
|
|
||||||
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
||||||
// Nothing watched: the chain idles out until a new request restarts it.
|
// Nothing watched: the chain idles out until a new request restarts it.
|
||||||
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
||||||
@@ -490,10 +459,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute the requested statuses against the tracking refs only.
|
/// Recompute the requested statuses against the tracking refs only.
|
||||||
///
|
|
||||||
/// The refs were last refreshed by a full pass. Comparing against them is
|
|
||||||
/// enough to pick up new local commits, and skipping the network keeps
|
|
||||||
/// this pass cheap enough to run every [`LOCAL_POLL`].
|
|
||||||
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
let associations = self.by_repo.clone();
|
let associations = self.by_repo.clone();
|
||||||
|
|
||||||
@@ -635,10 +600,6 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `ready to push` status of one checkout of the user's own repository.
|
/// The `ready to push` status of one checkout of the user's own repository.
|
||||||
///
|
|
||||||
/// `fetch` refreshes the remote heads first, so a full pass sees pushes made
|
|
||||||
/// elsewhere; the fast local pass skips it and compares against the tracking
|
|
||||||
/// refs left by the last full pass, which is enough to detect local commits.
|
|
||||||
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||||
if signed_git::worktree_dirty(path) {
|
if signed_git::worktree_dirty(path) {
|
||||||
return None;
|
return None;
|
||||||
@@ -649,8 +610,6 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|||||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||||
|
|
||||||
if fetch {
|
if fetch {
|
||||||
// Refresh the remote heads first.
|
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,10 +637,6 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the requested statuses against the checkout paths of `associations`.
|
/// Compute the requested statuses against the checkout paths of `associations`.
|
||||||
///
|
|
||||||
/// Shared by the full and the local pass. `fetch` refreshes the checkouts'
|
|
||||||
/// remote heads first, so the full pass sees remote moves; the fast local
|
|
||||||
/// pass reads the tracking refs only, which is enough to detect local commits.
|
|
||||||
fn compute_statuses(
|
fn compute_statuses(
|
||||||
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
requested: &[(RepoAddr, Option<String>)],
|
requested: &[(RepoAddr, Option<String>)],
|
||||||
@@ -737,12 +692,14 @@ pub fn pr_proposes_checkout(
|
|||||||
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let branch_matches = pr
|
let branch_matches = pr
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find(|t| t.kind() == "branch-name")
|
.find(|t| t.kind() == "branch-name")
|
||||||
.and_then(|t| t.content())
|
.and_then(|t| t.content())
|
||||||
.is_some_and(|name| name == checkout.branch);
|
.is_some_and(|name| name == checkout.branch);
|
||||||
|
|
||||||
// A renamed branch falls back to the proposed tip commit.
|
// A renamed branch falls back to the proposed tip commit.
|
||||||
let tip_matches = pr
|
let tip_matches = pr
|
||||||
.tags
|
.tags
|
||||||
@@ -750,6 +707,7 @@ pub fn pr_proposes_checkout(
|
|||||||
.find(|t| t.kind() == "c")
|
.find(|t| t.kind() == "c")
|
||||||
.and_then(|t| t.content())
|
.and_then(|t| t.content())
|
||||||
.is_some_and(|tip| tip == checkout.head);
|
.is_some_and(|tip| tip == checkout.head);
|
||||||
|
|
||||||
branch_matches || tip_matches
|
branch_matches || tip_matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,11 +104,16 @@ impl Inbox {
|
|||||||
|
|
||||||
/// Sign the state with a random key and store it locally.
|
/// Sign the state with a random key and store it locally.
|
||||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
let backend = Backend::global(cx);
|
||||||
|
let (me, client) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(me) = me else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = Backend::global(cx).read(cx).client();
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
||||||
WeakEntity,
|
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
|
||||||
|
/// How long to wait for more requests before firing a batched fetch.
|
||||||
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
/// Max authors per profile request, keeping each filter within relay limits.
|
||||||
|
const REQUEST_CHUNK: usize = 100;
|
||||||
|
/// Recent profiles prefetched at startup and read back from the cache.
|
||||||
|
const WARM_LIMIT: usize = 500;
|
||||||
|
|
||||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
/// 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 {
|
||||||
@@ -62,9 +68,6 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long to wait for more requests before firing a batched sync.
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
|
|
||||||
/// Global profile cache.
|
/// Global profile cache.
|
||||||
///
|
///
|
||||||
/// Profiles are fetched in batches and kept as plain data.
|
/// Profiles are fetched in batches and kept as plain data.
|
||||||
@@ -92,40 +95,26 @@ impl ProfileStore {
|
|||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
BackendEvent::NostrUpdate(updates) => {
|
|
||||||
for update in updates
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
.iter()
|
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||||
.filter(|update| update.kind == Kind::Metadata)
|
this.apply_authors(authors.clone(), cx);
|
||||||
{
|
|
||||||
this.apply_author(update.author, cx);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
|
||||||
this.profiles
|
|
||||||
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
cx.spawn(async move |this, cx| {
|
||||||
let client = backend.read(cx).client();
|
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
log::error!("Failed to handle requests: {e}");
|
||||||
let entity = cx.entity().downgrade();
|
}
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| {
|
|
||||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||||
log::warn!("profile store dropped before initial load could run: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -160,11 +149,9 @@ impl ProfileStore {
|
|||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// 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| {
|
||||||
@@ -176,7 +163,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -186,53 +173,13 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
/// Re-read the latest metadata of `authors` from the local database in one query.
|
||||||
let backend = Backend::global(cx);
|
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
// Parse off the main thread.
|
|
||||||
// Only the profile crosses back.
|
|
||||||
let profile = events
|
|
||||||
.into_iter()
|
|
||||||
.max_by_key(|e| e.created_at)
|
|
||||||
.map(|event| {
|
|
||||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
|
||||||
Profile::new(event.pubkey, metadata)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok::<_, Error>(profile)
|
|
||||||
});
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
|
||||||
let profile = work.await?;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
if let Some(profile) = profile {
|
|
||||||
this.profiles.insert(profile.public_key(), profile);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
|
||||||
///
|
|
||||||
/// Used after a sync, which produces no NostrUpdate events.
|
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
|
||||||
|
|
||||||
if authors.is_empty() {
|
if authors.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -243,9 +190,9 @@ impl ProfileStore {
|
|||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Pick the latest metadata per author off the main thread.
|
// Pick the latest metadata per author off the main thread.
|
||||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match latest.get(&event.pubkey) {
|
match latest.get(&event.pubkey) {
|
||||||
Some((ts, _)) if *ts >= event.created_at => {}
|
Some((ts, _)) if *ts >= event.created_at => {}
|
||||||
@@ -269,7 +216,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -279,14 +226,18 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
///
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
|
self.apply_authors(authors, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch metadata for requested authors in batches, debounced to collect requests.
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
this: WeakEntity<ProfileStore>,
|
this: WeakEntity<ProfileStore>,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -305,15 +256,20 @@ impl ProfileStore {
|
|||||||
|
|
||||||
// The channel has no async timeout, race the receive against a timer.
|
// The channel has no async timeout, race the receive against a timer.
|
||||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let recv = receiver.recv_async();
|
let recv = receiver.recv_async();
|
||||||
futures::pin_mut!(recv);
|
futures::pin_mut!(recv);
|
||||||
|
|
||||||
match futures::future::select(recv, timer).await {
|
match futures::future::select(recv, timer).await {
|
||||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
@@ -323,18 +279,21 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||||
|
|
||||||
|
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||||
|
let opts = SyncOptions::default();
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
.authors(chunk.iter().copied());
|
||||||
|
|
||||||
// Negentropy-sync with the bootstrap relays.
|
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
||||||
// Synced events are written to the database directly, no NostrUpdate.
|
log::warn!("profile fetch failed: {e}");
|
||||||
// Re-apply from the database afterwards.
|
|
||||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
|
||||||
Ok(_) => {
|
|
||||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
}
|
||||||
|
|
||||||
|
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ impl RefreshGate {
|
|||||||
self.running
|
self.running
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new refresh request arrived.
|
|
||||||
///
|
|
||||||
/// Folded into a follow-up run while one is in flight, otherwise the
|
|
||||||
/// caller starts the run itself.
|
|
||||||
pub fn request(&mut self) -> RefreshRequest {
|
pub fn request(&mut self) -> RefreshRequest {
|
||||||
if self.running {
|
if self.running {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
|||||||
+214
-175
@@ -4,14 +4,16 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use anyhow::{Error, bail};
|
use anyhow::{Error, bail};
|
||||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||||
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
use gpui::{App, AppContext, Context, SharedString, Subscription, Task};
|
||||||
use nostr::event::IntoEventBuilder;
|
use nostr::event::IntoEventBuilder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::{EventFetchingStrategy, SettingsStore};
|
||||||
use signed_core::{
|
use signed_core::{
|
||||||
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
||||||
pull_request_patches,
|
pull_request_patches,
|
||||||
};
|
};
|
||||||
use signed_git::Nip34Binding;
|
use signed_git::Nip34Binding;
|
||||||
|
use signed_nostr::UniversalSigner;
|
||||||
|
|
||||||
use crate::backend::{
|
use crate::backend::{
|
||||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
||||||
@@ -19,7 +21,6 @@ use crate::backend::{
|
|||||||
};
|
};
|
||||||
use crate::checkouts::CheckoutsStore;
|
use crate::checkouts::CheckoutsStore;
|
||||||
use crate::git_store::ensure_repo_mirror;
|
use crate::git_store::ensure_repo_mirror;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
/// Maximum size of one patch event.
|
/// Maximum size of one patch event.
|
||||||
@@ -30,7 +31,6 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
|||||||
/// Per-repository store.
|
/// Per-repository store.
|
||||||
///
|
///
|
||||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||||
/// Always derived from the local database.
|
|
||||||
pub struct RepoStore {
|
pub struct RepoStore {
|
||||||
/// NIP-34 address. `None` while the repository is local-only.
|
/// NIP-34 address. `None` while the repository is local-only.
|
||||||
addr: Option<RepoAddr>,
|
addr: Option<RepoAddr>,
|
||||||
@@ -84,7 +84,12 @@ pub struct RepoStore {
|
|||||||
///
|
///
|
||||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||||
root_fetches: HashSet<EventId>,
|
root_fetches: HashSet<EventId>,
|
||||||
refresh: RefreshGate,
|
/// Maintainers already synced through gossip in Uncensored mode.
|
||||||
|
///
|
||||||
|
/// Avoids re-running the maintainer Auto sync on every refresh.
|
||||||
|
synced_maintainers: HashSet<PublicKey>,
|
||||||
|
/// In-flight tasks, cancelled when the store drops.
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Backend subscription of an announced repository. `None` while local-only.
|
/// Backend subscription of an announced repository. `None` while local-only.
|
||||||
_subscription: Option<Subscription>,
|
_subscription: Option<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -132,7 +137,8 @@ impl RepoStore {
|
|||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
synced_maintainers: HashSet::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscription: Some(subscription),
|
_subscription: Some(subscription),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +166,8 @@ impl RepoStore {
|
|||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
synced_maintainers: HashSet::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscription: None,
|
_subscription: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,34 +208,22 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
BackendEvent::RepoUpdates(updates) => updates.iter().any(|update| {
|
||||||
// Deletions may target any event of this repository.
|
|
||||||
let deletion =
|
let deletion =
|
||||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
let coordinate = update.coordinate.as_ref() == Some(addr);
|
let coordinate = update.coordinate.as_ref() == Some(addr);
|
||||||
let author = update.author == addr.public_key;
|
let author = update.author == addr.public_key;
|
||||||
let kind = update.kind == Kind::GitRepoAnnouncement;
|
|
||||||
// NIP-22 comments carry no `a` tag.
|
let authored = (update.kind == Kind::GitRepoAnnouncement
|
||||||
// Coordinate matching fails for them.
|
|| update.kind == Kind::RepoState)
|
||||||
// Any comment may reference this repository's roots.
|
&& author;
|
||||||
|
|
||||||
let comment = update.kind == Kind::Comment;
|
let comment = update.kind == Kind::Comment;
|
||||||
// Status events may omit their `a` tag, NIP-34.
|
|
||||||
// Any status event may reference a root of this repository.
|
|
||||||
let status = RepoStatus::from_kind(update.kind).is_some();
|
let status = RepoStatus::from_kind(update.kind).is_some();
|
||||||
|
|
||||||
deletion || coordinate || (author && kind) || comment || status
|
deletion || coordinate || authored || comment || status
|
||||||
}),
|
}),
|
||||||
BackendEvent::Published(event) => {
|
|
||||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
|
||||||
let author = event.pubkey == addr.public_key;
|
|
||||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
|
|
||||||
// Locally published deletions may target any event of this repository.
|
|
||||||
// Refresh so they take effect immediately, like relay deletions.
|
|
||||||
let deletion =
|
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
coordinate || (kind && author) || deletion
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,8 +251,6 @@ impl RepoStore {
|
|||||||
/// Announcement, state, 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.
|
|
||||||
// They combine into one filter, one fewer negentropy reconciliation per relay.
|
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||||
.author(addr.public_key)
|
.author(addr.public_key)
|
||||||
@@ -287,9 +280,71 @@ impl RepoStore {
|
|||||||
self.repo_relays.extend(new.iter().cloned());
|
self.repo_relays.extend(new.iter().cloned());
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let filters = Self::repo_filters(&addr);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
|
backend.connect_repo_relays(new, filters, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
|
||||||
|
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
|
||||||
|
let mut pubkeys = maintainers.to_vec();
|
||||||
|
// NIP-34 events tag the announcement author,
|
||||||
|
// which may not be a maintainer for subordinate forks.
|
||||||
|
if !pubkeys.contains(&addr.public_key) {
|
||||||
|
pubkeys.push(addr.public_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![
|
||||||
|
// Announcement and state events, including co-maintainer states.
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||||
|
.authors(pubkeys.clone())
|
||||||
|
.identifier(addr.identifier.clone()),
|
||||||
|
// Activity tagging a maintainer, resolved to their read relays.
|
||||||
|
Filter::new()
|
||||||
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
|
.coordinate(addr)
|
||||||
|
.pubkeys(pubkeys.clone()),
|
||||||
|
// Activity authored by a maintainer, resolved to their write relays.
|
||||||
|
Filter::new()
|
||||||
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
|
.coordinate(addr)
|
||||||
|
.authors(pubkeys.clone()),
|
||||||
|
// Deletions authored by a maintainer.
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
|
.authors(pubkeys),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting
|
||||||
|
fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context<Self>) {
|
||||||
|
let strategy = SettingsStore::try_global(cx)
|
||||||
|
.map(|store| store.read(cx).settings().event_fetching)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if strategy != EventFetchingStrategy::Uncensored {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(addr) = self.addr.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !maintainers
|
||||||
|
.iter()
|
||||||
|
.any(|public_key| !self.synced_maintainers.contains(public_key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.synced_maintainers.extend(maintainers.iter().copied());
|
||||||
|
|
||||||
|
let filters = Self::maintainer_filters(&addr, maintainers);
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
|
backend.update(cx, |backend, cx| {
|
||||||
|
backend.sync_auto(filters, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,18 +361,10 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-query the local database and update all fields.
|
/// Re-query the local database and update all fields.
|
||||||
///
|
|
||||||
/// Runs immediately. The backend pump already batches the relay events that
|
|
||||||
/// trigger a refresh, so no per-store debounce is needed.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.addr.is_none() {
|
if self.addr.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.refresh.request() != RefreshRequest::Schedule {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.run_refresh(cx);
|
self.run_refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,8 +373,6 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.refresh.begin();
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
@@ -345,11 +390,10 @@ 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 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));
|
||||||
|
|
||||||
let announcement = latest(all_announcements)
|
let announcement = latest(all_announcements)
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(Announcement::from_event);
|
.and_then(Announcement::from_event);
|
||||||
@@ -374,8 +418,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-22 comments reference their root via an `E` or `e` tag.
|
|
||||||
// Not the repository's `a` tag, so query them by the root events.
|
|
||||||
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
|
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
|
|
||||||
@@ -393,8 +435,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status events may omit their `a` tag.
|
|
||||||
// Query them by the root events they reference too.
|
|
||||||
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
|
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
|
|
||||||
@@ -417,9 +457,6 @@ impl RepoStore {
|
|||||||
sort_newest_first(&mut pull_requests);
|
sort_newest_first(&mut pull_requests);
|
||||||
sort_oldest_first(&mut comments);
|
sort_oldest_first(&mut comments);
|
||||||
|
|
||||||
// Resolve every root's status once here.
|
|
||||||
// Render paths do HashMap lookups instead of per-root status scans.
|
|
||||||
// Those scans are quadratic, with an allocation per pair.
|
|
||||||
let maintainers = announcement
|
let maintainers = announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(Announcement::effective_maintainers)
|
.map(Announcement::effective_maintainers)
|
||||||
@@ -454,7 +491,7 @@ impl RepoStore {
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
let (
|
let (
|
||||||
announcement,
|
announcement,
|
||||||
state,
|
state,
|
||||||
@@ -469,33 +506,20 @@ impl RepoStore {
|
|||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
this.refresh.abort();
|
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let again = this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
// Compare before moving the freshly queried data in, so a pass
|
|
||||||
// that found nothing new does not notify observers. The store
|
|
||||||
// is polled in bursts while a sync is in flight; notifying on
|
|
||||||
// every identical pass would re-render the repository panel
|
|
||||||
// several times for no visible change.
|
|
||||||
//
|
|
||||||
// The first pass is the exception: it must notify even when it
|
|
||||||
// found nothing, so views can leave their loading state and show
|
|
||||||
// the empty result.
|
|
||||||
//
|
|
||||||
// Keep the open-time hint until that first pass has confirmed what
|
|
||||||
// the database holds; afterwards the database is the truth,
|
|
||||||
// including a deletion.
|
|
||||||
let keep_hint = announcement.is_none() && !this.loaded;
|
let keep_hint = announcement.is_none() && !this.loaded;
|
||||||
|
|
||||||
let first_pass = !this.loaded;
|
let first_pass = !this.loaded;
|
||||||
|
|
||||||
let head_changed = state
|
let head_changed = state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
||||||
|
|
||||||
let changed = first_pass
|
let changed = first_pass
|
||||||
|| (!keep_hint && this.announcement != announcement)
|
|| (!keep_hint && this.announcement != announcement)
|
||||||
|| head_changed
|
|| head_changed
|
||||||
@@ -510,14 +534,23 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The announcement may list relays for this repository's activity.
|
// The announcement may list relays for this repository's activity.
|
||||||
// Connect to any we have not fetched from yet.
|
|
||||||
let relays = this
|
let relays = this
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.relays.clone())
|
.map(|a| a.relays.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
this.connect_announced_relays(&relays, cx);
|
this.connect_announced_relays(&relays, cx);
|
||||||
|
|
||||||
|
// Uncensored mode also covers the maintainers' NIP-65 relays.
|
||||||
|
let maintainers = this
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(Announcement::effective_maintainers)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
this.sync_maintainer_relays(&maintainers, cx);
|
||||||
|
|
||||||
if let Some((_, head)) = state {
|
if let Some((_, head)) = state {
|
||||||
this.head = head;
|
this.head = head;
|
||||||
}
|
}
|
||||||
@@ -531,10 +564,6 @@ impl RepoStore {
|
|||||||
this.open_pr_count = open_pr_count;
|
this.open_pr_count = open_pr_count;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
|
||||||
// Comments and statuses without an `a` tag.
|
|
||||||
// None are addressed to the repository.
|
|
||||||
// Fetch them by the root events they reference.
|
|
||||||
// Use the bootstrap relays and the relays this repository announced.
|
|
||||||
let roots = this
|
let roots = this
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
@@ -551,13 +580,13 @@ 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 filter per root costs a 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()));
|
||||||
|
|
||||||
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.subscribe_bootstrap(root_filters.clone(), cx);
|
backend.subscribe_bootstrap(root_filters.clone(), cx);
|
||||||
backend.connect_repo_relays(announced, root_filters, cx);
|
backend.connect_repo_relays(announced, root_filters, cx);
|
||||||
@@ -567,19 +596,12 @@ impl RepoStore {
|
|||||||
if changed {
|
if changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.refresh.finish()
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Requests that arrived while the refresh was running.
|
|
||||||
// They are coalesced into one follow-up refresh.
|
|
||||||
if again {
|
|
||||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
||||||
@@ -588,25 +610,16 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Number of open issues.
|
/// Number of open issues.
|
||||||
///
|
|
||||||
/// 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.
|
/// Number of open pull requests.
|
||||||
///
|
|
||||||
/// Only root PR events count, PR updates do not.
|
|
||||||
/// They must resolve to [`RepoStatus::Open`].
|
|
||||||
pub fn pull_request_count(&self) -> usize {
|
pub fn pull_request_count(&self) -> usize {
|
||||||
self.open_pr_count
|
self.open_pr_count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `user` is the author or owner of this repository.
|
/// Whether `user` is the author or owner of this repository.
|
||||||
///
|
|
||||||
/// The author is the public key of the repository address.
|
|
||||||
/// Only the author may manage pull requests, close, reopen or merge.
|
|
||||||
pub fn is_author(&self, user: &PublicKey) -> bool {
|
pub fn is_author(&self, user: &PublicKey) -> bool {
|
||||||
self.addr
|
self.addr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -721,9 +734,12 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let signer = backend.read(cx).signer();
|
let (signer, client, user) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.signer(), backend.client(), backend.current_user())
|
||||||
|
};
|
||||||
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let Some(user) = user else {
|
||||||
self.last_error = Some("Sign in to open a pull request".into());
|
self.last_error = Some("Sign in to open a pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -758,12 +774,12 @@ impl RepoStore {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
// The PR references the root patch event.
|
// The PR references the root patch,
|
||||||
// Viewers can then find the patch without carrying it inline.
|
// 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,
|
&client,
|
||||||
cx,
|
&signer,
|
||||||
&addr,
|
&addr,
|
||||||
owner,
|
owner,
|
||||||
euc.as_deref(),
|
euc.as_deref(),
|
||||||
@@ -788,11 +804,14 @@ impl RepoStore {
|
|||||||
// Resolve the servers from the author's latest kind-10317 grasp list.
|
// Resolve the servers from the author's latest kind-10317 grasp list.
|
||||||
// The settings defaults stand in when no list is published or the query fails.
|
// The settings defaults stand in when no list is published or the query fails.
|
||||||
let author_servers = {
|
let author_servers = {
|
||||||
let query = this.update(cx, |_this, cx| {
|
let query_client = client.clone();
|
||||||
let client = Backend::global(cx).read(cx).client();
|
let published = cx
|
||||||
user_grasp_list_servers(client, user)
|
.background_spawn(
|
||||||
})?;
|
async move { user_grasp_list_servers(&query_client, user).await },
|
||||||
match cx.background_spawn(query).await {
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match published {
|
||||||
Ok(published) if !published.is_empty() => published,
|
Ok(published) if !published.is_empty() => published,
|
||||||
_ => defaults,
|
_ => defaults,
|
||||||
}
|
}
|
||||||
@@ -826,24 +845,17 @@ 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.
|
|
||||||
// The tip commit is downloadable from it.
|
|
||||||
// The author's `/prs/` URLs come first.
|
|
||||||
// They are author-controlled and most likely alive.
|
|
||||||
// The announced mirrors follow.
|
|
||||||
// The list is fixed before signing.
|
|
||||||
// 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())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let base_clone = this
|
let base_clone = this
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let clone = pr_clone_urls(prs_urls, base_clone);
|
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||||
|
|
||||||
let builder = GitPullRequest {
|
let builder = GitPullRequest {
|
||||||
@@ -868,8 +880,6 @@ impl RepoStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Sign before publishing.
|
// Sign before publishing.
|
||||||
// The tip is pushed to the grasp servers under `refs/nostr/<event-id>`.
|
|
||||||
// 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();
|
||||||
@@ -880,20 +890,22 @@ impl RepoStore {
|
|||||||
if let Some(path) = push_from.as_ref() {
|
if let Some(path) = push_from.as_ref() {
|
||||||
let tip = current_commit.to_string();
|
let tip = current_commit.to_string();
|
||||||
let reference = format!("refs/nostr/{}", event.id.to_hex());
|
let reference = format!("refs/nostr/{}", event.id.to_hex());
|
||||||
|
|
||||||
let (pushed, failures) = cx
|
let (pushed, failures) = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
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 announced base grasp servers.
|
// Author servers first, then the announced base grasp servers.
|
||||||
// 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)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
let mut pushed = 0;
|
let mut pushed = 0;
|
||||||
|
|
||||||
for (url, label) in &targets {
|
for (url, label) in &targets {
|
||||||
match signed_git::push_commit_ref(
|
match signed_git::push_commit_ref(
|
||||||
&path, url, &tip, &reference,
|
&path, url, &tip, &reference,
|
||||||
@@ -902,6 +914,7 @@ impl RepoStore {
|
|||||||
Err(e) => failures.push(format!("{label}: {e}")),
|
Err(e) => failures.push(format!("{label}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(pushed, failures)
|
(pushed, failures)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -918,8 +931,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?;
|
|
||||||
|
|
||||||
let publish_result: Result<Event, Error> = async {
|
let publish_result: Result<Event, Error> = async {
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
require_relay_accepted(output, event)
|
require_relay_accepted(output, event)
|
||||||
@@ -936,11 +947,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx)
|
|
||||||
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// A draft PR carries a kind-1633 status event, NIP-34.
|
// 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 {
|
||||||
@@ -950,8 +956,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
||||||
@@ -1015,8 +1021,12 @@ impl RepoStore {
|
|||||||
self.last_warning = None;
|
self.last_warning = None;
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let (user, client, signer) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client(), backend.signer())
|
||||||
|
};
|
||||||
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let Some(user) = user else {
|
||||||
self.last_error = Some("Sign in to update the pull request".into());
|
self.last_error = Some("Sign in to update the pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -1070,19 +1080,21 @@ impl RepoStore {
|
|||||||
self.not_announced(cx);
|
self.not_announced(cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let owner = addr.public_key;
|
let owner = addr.public_key;
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
|
||||||
let clone: Vec<Url> = self
|
let clone: Vec<Url> = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = publish_patch_series(
|
if let Err(e) = publish_patch_series(
|
||||||
&this,
|
&client,
|
||||||
cx,
|
&signer,
|
||||||
&addr,
|
&addr,
|
||||||
owner,
|
owner,
|
||||||
euc.as_deref(),
|
euc.as_deref(),
|
||||||
@@ -1098,7 +1110,7 @@ impl RepoStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = this.update(cx, |_this, _cx| {
|
let builder = {
|
||||||
let builder = GitPullRequestUpdate {
|
let builder = GitPullRequestUpdate {
|
||||||
repository: addr.clone(),
|
repository: addr.clone(),
|
||||||
pull_request_event: root.id,
|
pull_request_event: root.id,
|
||||||
@@ -1115,13 +1127,7 @@ impl RepoStore {
|
|||||||
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,
|
||||||
}
|
}
|
||||||
})?;
|
};
|
||||||
|
|
||||||
let (client, signer) = this.update(cx, |_this, cx| {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.client(), backend.signer())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let publish_result: Result<Event, Error> = async {
|
let publish_result: Result<Event, Error> = async {
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
@@ -1130,25 +1136,16 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match publish_result {
|
if let Err(e) = publish_result {
|
||||||
Ok(event) => {
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
|
||||||
backend.announce_published(event.clone(), cx)
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the status of a root event.
|
/// Set the status of a root event.
|
||||||
@@ -1274,7 +1271,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest announcement of this repository,
|
/// The latest announcement of this repository,
|
||||||
@@ -1581,24 +1578,16 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match publish_result {
|
if let Err(e) = publish_result {
|
||||||
Ok(event) => {
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx)
|
|
||||||
.update(cx, |backend, cx| backend.announce_published(event, cx))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1668,8 +1657,8 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
|||||||
/// Returns the root event, the one a PR references.
|
/// Returns the root event, the one a PR references.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn publish_patch_series(
|
async fn publish_patch_series(
|
||||||
this: &WeakEntity<RepoStore>,
|
client: &Client,
|
||||||
cx: &mut AsyncApp,
|
signer: &UniversalSigner,
|
||||||
addr: &RepoAddr,
|
addr: &RepoAddr,
|
||||||
owner: PublicKey,
|
owner: PublicKey,
|
||||||
euc: Option<&str>,
|
euc: Option<&str>,
|
||||||
@@ -1677,12 +1666,6 @@ async fn publish_patch_series(
|
|||||||
first_marker: &str,
|
first_marker: &str,
|
||||||
reply_to: Option<EventId>,
|
reply_to: Option<EventId>,
|
||||||
) -> Result<Event, Error> {
|
) -> Result<Event, Error> {
|
||||||
let (client, signer) = this.update(cx, |_this, cx| {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.client(), backend.signer())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut root: Option<Event> = None;
|
let mut root: Option<Event> = None;
|
||||||
let mut previous = reply_to;
|
let mut previous = reply_to;
|
||||||
|
|
||||||
@@ -1695,6 +1678,7 @@ async fn publish_patch_series(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
||||||
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
@@ -1709,28 +1693,25 @@ async fn publish_patch_series(
|
|||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(euc) = euc
|
if let Some(euc) = euc
|
||||||
&& let Ok(tag) = Tag::parse(["r", euc])
|
&& let Ok(tag) = Tag::parse(["r", euc])
|
||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tag) = Tag::parse(["commit", commit]) {
|
if let Ok(tag) = Tag::parse(["commit", commit]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tag) = Tag::parse(["r", commit]) {
|
if let Ok(tag) = Tag::parse(["r", commit]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
||||||
|
let event = builder.finalize_async(signer).await?;
|
||||||
let event = builder.finalize_async(&signer).await?;
|
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
let event = require_relay_accepted(output, event)?;
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
|
||||||
backend.announce_published(event.clone(), cx)
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if root.is_none() {
|
if root.is_none() {
|
||||||
root = Some(event.clone());
|
root = Some(event.clone());
|
||||||
@@ -1769,9 +1750,11 @@ fn comment_builder(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use super::{comment_builder, patch_current_commit};
|
use super::{RepoStore, comment_builder, patch_current_commit};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_format_patch_header() {
|
fn parses_format_patch_header() {
|
||||||
@@ -1817,4 +1800,60 @@ mod tests {
|
|||||||
// Signed's own `references_root` must keep matching the comment.
|
// Signed's own `references_root` must keep matching the comment.
|
||||||
assert!(signed_core::references_root(&event, &root.id));
|
assert!(signed_core::references_root(&event, &root.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maintainer_filters_name_owner_and_maintainers() {
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let maintainer = Keys::generate().public_key();
|
||||||
|
let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo");
|
||||||
|
|
||||||
|
// The owner is not among the maintainers, as on a subordinate fork.
|
||||||
|
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
|
||||||
|
assert_eq!(filters.len(), 4);
|
||||||
|
|
||||||
|
let expected = HashSet::from([owner, maintainer]);
|
||||||
|
|
||||||
|
// Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag.
|
||||||
|
let named = |filter: &Filter| -> HashSet<PublicKey> {
|
||||||
|
let authors = filter.authors.iter().flatten().copied();
|
||||||
|
let p_tag = filter
|
||||||
|
.generic_tags
|
||||||
|
.get(&SingleLetterTag::LOWERCASE_P)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|value| PublicKey::from_hex(value).ok());
|
||||||
|
authors.chain(p_tag).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Announcement and state events, scoped to the repository identifier.
|
||||||
|
let announcement = &filters[0];
|
||||||
|
assert_eq!(named(announcement), expected);
|
||||||
|
assert!(
|
||||||
|
announcement
|
||||||
|
.generic_tags
|
||||||
|
.contains_key(&SingleLetterTag::LOWERCASE_D)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Activity filters, scoped to the repository coordinate.
|
||||||
|
for filter in &filters[1..3] {
|
||||||
|
assert_eq!(named(filter), expected);
|
||||||
|
assert!(
|
||||||
|
filter
|
||||||
|
.generic_tags
|
||||||
|
.contains_key(&SingleLetterTag::LOWERCASE_A)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletions, named by author only.
|
||||||
|
let deletions = &filters[3];
|
||||||
|
assert_eq!(
|
||||||
|
deletions
|
||||||
|
.authors
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -47,6 +47,8 @@ pub struct RepoListStore {
|
|||||||
///
|
///
|
||||||
/// Used for the Popular 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>>,
|
||||||
|
/// Own repositories whose state events were fetched from their announced relays.
|
||||||
|
state_synced_repos: HashSet<RepoAddr>,
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
@@ -66,33 +68,11 @@ 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(updates) => updates.iter().any(|update| {
|
BackendEvent::SignerChanged => {
|
||||||
// Deletions may target anything we list, always refresh.
|
this.state_synced_repos.clear();
|
||||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
|
||||||
true
|
true
|
||||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
|
||||||
// Activity events are addressed to repos via `a` tags.
|
|
||||||
// Their author is not the repo owner, always refresh.
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
|
||||||
let is_repo_state = update.kind == Kind::RepoState;
|
|
||||||
is_announcement || is_repo_state
|
|
||||||
}
|
}
|
||||||
}),
|
BackendEvent::RepoUpdates(_) => true,
|
||||||
BackendEvent::Published(event) => {
|
|
||||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
|
||||||
|
|
||||||
// Locally published deletions are already in the local database.
|
|
||||||
// Refresh so they take effect immediately, like relay deletions.
|
|
||||||
let deletion =
|
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
announcement || deletion
|
|
||||||
}
|
|
||||||
// Only a completed sync refreshes the list.
|
|
||||||
// Progress ticks would re-scan the whole database several times
|
|
||||||
// per sync to reveal entries incrementally.
|
|
||||||
BackendEvent::Synced => true,
|
BackendEvent::Synced => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -114,6 +94,7 @@ impl RepoListStore {
|
|||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
last_activity: Arc::new(HashMap::new()),
|
last_activity: Arc::new(HashMap::new()),
|
||||||
counts: Arc::new(HashMap::new()),
|
counts: Arc::new(HashMap::new()),
|
||||||
|
state_synced_repos: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
@@ -132,12 +113,42 @@ impl RepoListStore {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
backend.sync_bootstraps(
|
||||||
|
vec![
|
||||||
|
filters::all_announcements(),
|
||||||
|
filters::all_states(),
|
||||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||||
backend.sync_bootstrap(filters::deletions(), cx);
|
filters::deletions(),
|
||||||
|
],
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the state events of the user's own repositories.
|
||||||
|
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let Some(me) = backend.read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pending: Vec<(RepoAddr, Vec<RelayUrl>)> = self
|
||||||
|
.announcements
|
||||||
|
.iter()
|
||||||
|
.filter(|announcement| announcement.owner == me && !announcement.relays.is_empty())
|
||||||
|
.map(|announcement| (announcement.addr(), announcement.relays.clone()))
|
||||||
|
.filter(|(addr, _)| !self.state_synced_repos.contains(addr))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (addr, relays) in pending {
|
||||||
|
self.state_synced_repos.insert(addr.clone());
|
||||||
|
|
||||||
|
backend.update(cx, |backend, cx| {
|
||||||
|
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-query the local database.
|
/// Re-query the local database.
|
||||||
///
|
///
|
||||||
/// Runs immediately. The backend pump already batches the relay events that
|
/// Runs immediately. The backend pump already batches the relay events that
|
||||||
@@ -189,9 +200,6 @@ 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 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))
|
||||||
@@ -217,6 +225,7 @@ impl RepoListStore {
|
|||||||
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);
|
||||||
|
|
||||||
for event in client.database().query(activity_filter).await? {
|
for event in client.database().query(activity_filter).await? {
|
||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
@@ -225,8 +234,6 @@ impl RepoListStore {
|
|||||||
if addr.kind != Kind::GitRepoAnnouncement {
|
if addr.kind != Kind::GitRepoAnnouncement {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Skip events for repos we do not list.
|
|
||||||
// The map cannot grow beyond the number of announcements.
|
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -239,13 +246,12 @@ impl RepoListStore {
|
|||||||
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]);
|
||||||
|
|
||||||
for event in client.database().query(count_filter).await? {
|
for event in client.database().query(count_filter).await? {
|
||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
for addr in event.tags.coordinates() {
|
||||||
// Skip events for repos we do not list.
|
|
||||||
// The map cannot grow beyond the number of announcements.
|
|
||||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -278,6 +284,7 @@ impl RepoListStore {
|
|||||||
this.announcements = Arc::new(announcements);
|
this.announcements = Arc::new(announcements);
|
||||||
this.last_activity = Arc::new(last_activity);
|
this.last_activity = Arc::new(last_activity);
|
||||||
this.counts = Arc::new(counts);
|
this.counts = Arc::new(counts);
|
||||||
|
this.sync_own_repo_states(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refresh.finish()
|
this.refresh.finish()
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||||
use gpui_component::avatar::Avatar;
|
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
|
||||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||||
|
|
||||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
use crate::pixel_avatar::{PixelAvatar, side_length};
|
||||||
/// It shows the user's picture or falls back to name initials.
|
|
||||||
|
/// A user avatar built on the unstyled [`gpui_base::Avatar`].
|
||||||
|
///
|
||||||
|
/// It shows the user's picture when one is set, and falls back to the
|
||||||
|
/// deterministic pixel avatar seeded from the name otherwise.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct UserAvatar {
|
pub struct Avatar {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
picture: Option<SharedString>,
|
picture: Option<SharedString>,
|
||||||
size: Size,
|
size: Size,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserAvatar {
|
impl Avatar {
|
||||||
/// Create an avatar for `name`.
|
/// Create an avatar for `name`.
|
||||||
/// The name seeds the initials fallback shown when no picture is set.
|
///
|
||||||
|
/// The name seeds the pixel fallback shown when no picture is set.
|
||||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
@@ -32,26 +37,35 @@ impl UserAvatar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sizable for UserAvatar {
|
impl Sizable for Avatar {
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||||
self.size = size.into();
|
self.size = size.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Styled for UserAvatar {
|
impl Styled for Avatar {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
&mut self.style
|
&mut self.style
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for UserAvatar {
|
impl RenderOnce for Avatar {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
Avatar::new()
|
let fallback = AvatarFallback::new()
|
||||||
.name(self.name)
|
.size_full()
|
||||||
.when_some(self.picture, |this, url| this.src(url))
|
.child(PixelAvatar::new(self.name.clone()).size_full());
|
||||||
|
|
||||||
|
BaseAvatar::new()
|
||||||
|
.size(side_length(self.size))
|
||||||
|
.flex_shrink_0()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(cx.theme().secondary)
|
||||||
|
.when_some(self.picture, |this, url| {
|
||||||
|
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
|
||||||
|
})
|
||||||
|
.fallback(fallback)
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
.with_size(self.size)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod avatar;
|
||||||
mod dropdown_button;
|
mod dropdown_button;
|
||||||
mod nav_item;
|
mod nav_item;
|
||||||
mod pixel_avatar;
|
mod pixel_avatar;
|
||||||
@@ -8,11 +9,11 @@ mod setting;
|
|||||||
mod status_badge;
|
mod status_badge;
|
||||||
mod title_bar;
|
mod title_bar;
|
||||||
mod tree_row;
|
mod tree_row;
|
||||||
mod user_avatar;
|
|
||||||
|
|
||||||
pub mod copy_row;
|
pub mod copy_row;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
|
pub use avatar::Avatar;
|
||||||
pub use copy_row::{copy_row, menu_copy_row};
|
pub use copy_row::{copy_row, menu_copy_row};
|
||||||
pub use dropdown_button::DropdownButton;
|
pub use dropdown_button::DropdownButton;
|
||||||
pub use nav_item::NavItem;
|
pub use nav_item::NavItem;
|
||||||
@@ -24,5 +25,4 @@ pub use setting::{SelectOption, setting_block, setting_row};
|
|||||||
pub use status_badge::status_badge;
|
pub use status_badge::status_badge;
|
||||||
pub use title_bar::title_bar_drag_handlers;
|
pub use title_bar::title_bar_drag_handlers;
|
||||||
pub use tree_row::tree_row;
|
pub use tree_row::tree_row;
|
||||||
pub use user_avatar::UserAvatar;
|
|
||||||
pub use util::middle_truncate;
|
pub use util::middle_truncate;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
|||||||
|
|
||||||
/// Number of rows and columns in the pixel grid.
|
/// Number of rows and columns in the pixel grid.
|
||||||
const GRID_SIZE: usize = 8;
|
const GRID_SIZE: usize = 8;
|
||||||
/// Probability that a cell in the left half is filled.
|
/// Empty cells kept between the pattern and the avatar edge, so the art
|
||||||
|
/// gathers in the center instead of filling the whole avatar.
|
||||||
|
const MARGIN: usize = 1;
|
||||||
|
/// Probability that a cell in the left half of the pattern area is filled.
|
||||||
const FILL_PROBABILITY: f32 = 0.42;
|
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;
|
||||||
@@ -22,12 +25,10 @@ pub struct PixelAvatar {
|
|||||||
|
|
||||||
impl PixelAvatar {
|
impl PixelAvatar {
|
||||||
/// Create an avatar seeded from `seed`.
|
/// Create an avatar seeded from `seed`.
|
||||||
///
|
|
||||||
/// The seed should be a stable string unique to the entity the avatar represents.
|
|
||||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||||
size: Size::XSmall,
|
size: Size::Small,
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,19 +80,21 @@ impl RenderOnce for PixelAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.refine_style(&self.style)
|
|
||||||
.grid()
|
.grid()
|
||||||
.grid_cols(GRID_SIZE as u16)
|
.grid_cols(GRID_SIZE as u16)
|
||||||
.grid_rows(GRID_SIZE as u16)
|
.grid_rows(GRID_SIZE as u16)
|
||||||
.size(side_length(self.size))
|
.size(side_length(self.size))
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.rounded(theme.radius)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(main.opacity(0.16))
|
.bg(main.opacity(0.16))
|
||||||
.children(cells)
|
.children(cells)
|
||||||
|
.refine_style(&self.style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn side_length(size: Size) -> Pixels {
|
/// The rendered side length of an avatar at `size`, shared with [`Avatar`].
|
||||||
|
pub(crate) fn side_length(size: Size) -> Pixels {
|
||||||
match size {
|
match size {
|
||||||
Size::XSmall => px(16.),
|
Size::XSmall => px(16.),
|
||||||
Size::Small => px(24.),
|
Size::Small => px(24.),
|
||||||
@@ -106,8 +109,13 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
|||||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||||
let mut filled = 0usize;
|
let mut filled = 0usize;
|
||||||
|
|
||||||
for row in 0..GRID_SIZE {
|
// Only the inner rows and the inner left half are candidates; mirroring
|
||||||
for col in 0..GRID_SIZE / 2 {
|
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||||
|
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||||
|
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||||
|
|
||||||
|
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||||
|
for col in MARGIN..GRID_SIZE / 2 {
|
||||||
if rng.chance(FILL_PROBABILITY) {
|
if rng.chance(FILL_PROBABILITY) {
|
||||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||||
@@ -117,17 +125,17 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if filled < MIN_FILLED {
|
if filled < MIN_FILLED {
|
||||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
let total = art_rows * art_columns;
|
||||||
let start = (rng.next() % half as u64) as usize;
|
let start = (rng.next() % total as u64) as usize;
|
||||||
|
|
||||||
for offset in 0..half {
|
for offset in 0..total {
|
||||||
if filled >= MIN_FILLED {
|
if filled >= MIN_FILLED {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ix = (start + offset) % half;
|
let ix = (start + offset) % total;
|
||||||
let row = ix / (GRID_SIZE / 2);
|
let row = MARGIN + ix / art_columns;
|
||||||
let col = ix % (GRID_SIZE / 2);
|
let col = MARGIN + ix % art_columns;
|
||||||
|
|
||||||
if pattern[row * GRID_SIZE + col] == 0 {
|
if pattern[row * GRID_SIZE + col] == 0 {
|
||||||
set_cell(&mut pattern, row, col, 1);
|
set_cell(&mut pattern, row, col, 1);
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ use gpui_component::{
|
|||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
||||||
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{placeholder, tree_row};
|
use signed_ui::{placeholder, tree_row};
|
||||||
use utils::relative_time_secs;
|
use utils::relative_time_secs;
|
||||||
|
|
||||||
use crate::views::tree::{build_tree_items, tree_items};
|
use crate::views::tree::{build_tree_items, tree_items};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const TREE_WIDTH: f32 = 260.;
|
const TREE_WIDTH: f32 = 260.;
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@ impl Render for DiffPane {
|
|||||||
|
|
||||||
pub struct CommitDiffView {
|
pub struct CommitDiffView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
store: Entity<RepoStore>,
|
||||||
worktree: PathBuf,
|
worktree: PathBuf,
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
commit: FileCommit,
|
commit: FileCommit,
|
||||||
@@ -300,10 +303,12 @@ pub struct CommitDiffView {
|
|||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommitDiffView {
|
impl CommitDiffView {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
store: Entity<RepoStore>,
|
||||||
worktree: PathBuf,
|
worktree: PathBuf,
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
commit_id: String,
|
commit_id: String,
|
||||||
@@ -319,6 +324,7 @@ impl CommitDiffView {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
store,
|
||||||
worktree,
|
worktree,
|
||||||
repo_name,
|
repo_name,
|
||||||
commit: FileCommit {
|
commit: FileCommit {
|
||||||
@@ -331,6 +337,7 @@ impl CommitDiffView {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
pane,
|
pane,
|
||||||
|
tasks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +385,7 @@ impl CommitDiffView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
@@ -465,11 +472,11 @@ impl BasePanel for CommitDiffView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for CommitDiffView {
|
impl Panel for CommitDiffView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().text_sm().child(SharedString::from(format!(
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
"{}/{}",
|
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||||
self.repo_name, self.commit.id
|
|
||||||
)))
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use gpui_component::tag::Tag;
|
|||||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::{Event, EventId, PublicKey};
|
use nostr::prelude::{Event, EventId, PublicKey};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::UserAvatar;
|
use signed_ui::Avatar;
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||||
@@ -71,7 +71,7 @@ pub(crate) fn sidebar_section(
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(name.clone()).picture(picture))
|
||||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})),
|
})),
|
||||||
@@ -137,7 +137,7 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
.child(Avatar::new(author.clone()).picture(picture))
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use gpui::{
|
|||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||||
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
|
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||||
};
|
};
|
||||||
use signed_ui::{CountBadge, UserAvatar};
|
use signed_ui::{Avatar, CountBadge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use super::{RepoItem, open_repo_item};
|
use super::{RepoItem, open_repo_item, panel_avatar, tab_title};
|
||||||
|
|
||||||
const LIST_OVERDRAW: Pixels = px(400.);
|
const LIST_OVERDRAW: Pixels = px(400.);
|
||||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||||
@@ -157,21 +157,8 @@ impl InboxView {
|
|||||||
|
|
||||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||||
match event {
|
match event {
|
||||||
BackendEvent::NostrUpdate(updates) => {
|
BackendEvent::RepoUpdates(_) => self.refresh(cx),
|
||||||
let relevant = updates.iter().any(|update| {
|
BackendEvent::Synced => self.refresh(cx),
|
||||||
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
|
||||||
let is_comment = update.kind == Kind::Comment;
|
|
||||||
let is_event_deletion = update.kind == Kind::EventDeletion;
|
|
||||||
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
|
||||||
});
|
|
||||||
|
|
||||||
if relevant {
|
|
||||||
self.refresh(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,12 +187,16 @@ impl InboxView {
|
|||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let Some(me) = backend.read(cx).current_user() else {
|
let (me, client) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(me) = me else {
|
||||||
self.refresh.abort();
|
self.refresh.abort();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
||||||
@@ -553,7 +544,7 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
UserAvatar::new(name.clone())
|
Avatar::new(name.clone())
|
||||||
.picture(profile.picture())
|
.picture(profile.picture())
|
||||||
.xsmall(),
|
.xsmall(),
|
||||||
)
|
)
|
||||||
@@ -570,10 +561,6 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn activity_phrase(kind: Kind) -> &'static str {
|
fn activity_phrase(kind: Kind) -> &'static str {
|
||||||
if kind == COVER_NOTE_KIND {
|
|
||||||
return "added a note";
|
|
||||||
}
|
|
||||||
|
|
||||||
match kind {
|
match kind {
|
||||||
Kind::GitIssue => "opened an issue",
|
Kind::GitIssue => "opened an issue",
|
||||||
Kind::GitPullRequest => "opened a PR",
|
Kind::GitPullRequest => "opened a PR",
|
||||||
@@ -619,7 +606,7 @@ impl BasePanel for InboxView {
|
|||||||
|
|
||||||
impl Panel for InboxView {
|
impl Panel for InboxView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().text_sm().child(SharedString::from("Inbox"))
|
tab_title(panel_avatar("inbox"), SharedString::from("Inbox"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
|||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::activity_subject;
|
use signed_core::activity_subject;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
pub struct IssueDetailView {
|
pub struct IssueDetailView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -65,7 +66,9 @@ impl Panel for IssueDetailView {
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||||
|
|
||||||
div().text_sm().child(short_id)
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
|
|
||||||
|
tab_title(avatar, short_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +153,7 @@ impl Render for IssueDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(Avatar::new(&author).picture(picture))
|
||||||
UserAvatar::new(&author).picture(picture),
|
|
||||||
)
|
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ use gpui_component::{
|
|||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{RepoStatus, activity_subject};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
pub(super) mod detail;
|
pub(super) mod detail;
|
||||||
|
|
||||||
use self::detail::IssueDetailView;
|
use self::detail::IssueDetailView;
|
||||||
use super::status_list::{StatusCounts, filter_by_status};
|
use super::status_list::{StatusCounts, filter_by_status};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ impl IssuesView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
.child(Avatar::new(author.clone()).picture(picture))
|
||||||
.child(div().child(author)),
|
.child(div().child(author)),
|
||||||
)
|
)
|
||||||
.child(SharedString::from("opened"))
|
.child(SharedString::from("opened"))
|
||||||
@@ -333,8 +334,11 @@ impl BasePanel for IssuesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for IssuesView {
|
impl Panel for IssuesView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!("{}/issues", self.repo_name)))
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
|
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||||
|
|
||||||
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,53 @@ pub(crate) mod sidebar;
|
|||||||
mod status_list;
|
mod status_list;
|
||||||
pub(crate) mod tree;
|
pub(crate) mod tree;
|
||||||
|
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{AnyElement, App, div};
|
||||||
|
use gpui_component::{Sizable as _, h_flex};
|
||||||
pub use inbox::InboxView;
|
pub use inbox::InboxView;
|
||||||
pub use repo::RepoDetailView;
|
pub use repo::RepoDetailView;
|
||||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
pub use sidebar::SidebarPanel;
|
pub use sidebar::SidebarPanel;
|
||||||
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
|
use signed_ui::{Avatar, PixelAvatar};
|
||||||
|
|
||||||
|
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.items_center()
|
||||||
|
.child(div().flex_shrink_0().child(avatar))
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
|
||||||
|
PixelAvatar::new(seed).xsmall().into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
||||||
|
let seed = store
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|announcement| format!("{}:{}", announcement.owner, announcement.id))
|
||||||
|
.or_else(|| store.addr().map(|addr| addr.to_string()))
|
||||||
|
.or_else(|| {
|
||||||
|
store
|
||||||
|
.path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let owner = store
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
|
||||||
|
|
||||||
|
match owner.and_then(|profile| profile.picture().map(|picture| (profile.name(), picture))) {
|
||||||
|
Some((name, picture)) => Avatar::new(name)
|
||||||
|
.picture(Some(picture))
|
||||||
|
.xsmall()
|
||||||
|
.into_any_element(),
|
||||||
|
None => PixelAvatar::new(seed).xsmall().into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ use signed_core::{
|
|||||||
};
|
};
|
||||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||||
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
||||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||||
use utils::{relative_time, relative_time_secs};
|
use utils::{relative_time, relative_time_secs};
|
||||||
|
|
||||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||||
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const ROW_HEIGHT: f32 = 37.;
|
const ROW_HEIGHT: f32 = 37.;
|
||||||
|
|
||||||
@@ -78,8 +79,7 @@ pub struct PullRequestDetailView {
|
|||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
/// The dock caches item panels, so without this observer a panel opened
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
/// before the store loaded would stay on its placeholder.
|
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ impl PullRequestDetailView {
|
|||||||
pane,
|
pane,
|
||||||
commit_item_sizes: Rc::new(Vec::new()),
|
commit_item_sizes: Rc::new(Vec::new()),
|
||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +327,7 @@ impl PullRequestDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||||
@@ -343,6 +344,7 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
let panel = cx.new(|cx| {
|
let panel = cx.new(|cx| {
|
||||||
CommitDiffView::new(
|
CommitDiffView::new(
|
||||||
|
self.store.clone(),
|
||||||
worktree,
|
worktree,
|
||||||
self.repo_name.clone(),
|
self.repo_name.clone(),
|
||||||
commit_id.into(),
|
commit_id.into(),
|
||||||
@@ -451,7 +453,7 @@ impl PullRequestDetailView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
UserAvatar::new(author.clone())
|
Avatar::new(author.clone())
|
||||||
.picture(picture),
|
.picture(picture),
|
||||||
)
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
@@ -788,15 +790,17 @@ impl BasePanel for PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for PullRequestDetailView {
|
impl Panel for PullRequestDetailView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
let hex = self.pr_id.to_hex();
|
let hex = self.pr_id.to_hex();
|
||||||
let id = SharedString::from(&hex[..8]);
|
let id = SharedString::from(&hex[..8]);
|
||||||
let title = if self.repo_name.is_empty() {
|
let label = if self.repo_name.is_empty() {
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
SharedString::from(format!("{}/{}", self.repo_name, id))
|
SharedString::from(format!("{}/{}", self.repo_name, id))
|
||||||
};
|
};
|
||||||
div().text_sm().child(title)
|
|
||||||
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use gpui_component::{
|
|||||||
use nostr::prelude::{EventId, Kind};
|
use nostr::prelude::{EventId, Kind};
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{RepoStatus, activity_subject};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
pub(super) mod detail;
|
pub(super) mod detail;
|
||||||
@@ -27,6 +27,7 @@ use self::new::open_new_pull_panel;
|
|||||||
use super::send_patch::open_send_patch_panel;
|
use super::send_patch::open_send_patch_panel;
|
||||||
use super::status_list::{StatusCounts, filter_by_status};
|
use super::status_list::{StatusCounts, filter_by_status};
|
||||||
use crate::views::repo::RepoAction;
|
use crate::views::repo::RepoAction;
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const ROW_HEIGHT: f32 = 73.;
|
const ROW_HEIGHT: f32 = 73.;
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
.child(Avatar::new(author.clone()).picture(picture))
|
||||||
.child(div().child(author)),
|
.child(div().child(author)),
|
||||||
)
|
)
|
||||||
.child(SharedString::from("opened"))
|
.child(SharedString::from("opened"))
|
||||||
@@ -333,11 +334,11 @@ impl BasePanel for PullRequestsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for PullRequestsView {
|
impl Panel for PullRequestsView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!(
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
"{}/pull-requests",
|
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||||
self.repo_name
|
|
||||||
)))
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use signed_state::{
|
|||||||
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
||||||
|
|
||||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
pub struct NewPullRequestView {
|
pub struct NewPullRequestView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -67,6 +68,7 @@ pub struct NewPullRequestView {
|
|||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +324,7 @@ impl NewPullRequestView {
|
|||||||
pane,
|
pane,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,7 +392,8 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Branches and the current branch are read off the UI thread, then applied.
|
/// Branches and the current branch are read off the UI thread, then applied.
|
||||||
@@ -418,7 +422,8 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_checkout(
|
fn apply_checkout(
|
||||||
@@ -635,7 +640,8 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -829,7 +835,7 @@ impl NewPullRequestView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -910,7 +916,7 @@ impl NewPullRequestView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>) {
|
||||||
@@ -924,6 +930,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
let panel = cx.new(|cx| {
|
let panel = cx.new(|cx| {
|
||||||
CommitDiffView::new(
|
CommitDiffView::new(
|
||||||
|
self.store.clone(),
|
||||||
repo_path,
|
repo_path,
|
||||||
self.repo_name.clone(),
|
self.repo_name.clone(),
|
||||||
commit_id.into(),
|
commit_id.into(),
|
||||||
@@ -1323,11 +1330,11 @@ impl BasePanel for NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for NewPullRequestView {
|
impl Panel for NewPullRequestView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!(
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
"{}/new-pull-request",
|
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
||||||
self.repo_name
|
|
||||||
)))
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
|||||||
use nostr::prelude::PublicKey;
|
use nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::ProfileStore;
|
use signed_state::ProfileStore;
|
||||||
use signed_ui::{UserAvatar, middle_truncate};
|
use signed_ui::{Avatar, middle_truncate};
|
||||||
|
|
||||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
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| {
|
||||||
@@ -173,7 +173,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(name.clone()).picture(picture))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ impl RepoHistoryView {
|
|||||||
// Same display name as the repo detail panel's title.
|
// Same display name as the repo detail panel's title.
|
||||||
let repo_name = repo_display_name(self.store.read(cx));
|
let repo_name = repo_display_name(self.store.read(cx));
|
||||||
|
|
||||||
let panel =
|
let store = self.store.clone();
|
||||||
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
|
let panel = cx.new(|cx| {
|
||||||
|
CommitDiffView::new(store, worktree, repo_name, commit_id.into(), window, cx)
|
||||||
|
});
|
||||||
|
|
||||||
dock_area.update(cx, |dock_area, cx| {
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ use signed_state::{
|
|||||||
pr_proposes_checkout,
|
pr_proposes_checkout,
|
||||||
};
|
};
|
||||||
use signed_ui::{
|
use signed_ui::{
|
||||||
CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate,
|
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate,
|
||||||
ref_selector_trigger,
|
ref_selector_trigger,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ use crate::views::pull_requests::new::open_new_pull_panel;
|
|||||||
use crate::views::repo::about::open_about_dialog;
|
use crate::views::repo::about::open_about_dialog;
|
||||||
use crate::views::send_patch::open_send_patch_panel;
|
use crate::views::send_patch::open_send_patch_panel;
|
||||||
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
|
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum RefKind {
|
enum RefKind {
|
||||||
@@ -1490,7 +1491,7 @@ impl RepoDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(owner_name.clone()).picture(owner_picture))
|
.child(Avatar::new(owner_name.clone()).picture(owner_picture))
|
||||||
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
||||||
)
|
)
|
||||||
.when(!rest.is_empty(), |this| {
|
.when(!rest.is_empty(), |this| {
|
||||||
@@ -2031,7 +2032,7 @@ fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().foreground)
|
.text_color(cx.theme().foreground)
|
||||||
.child(UserAvatar::new(profile.name()).picture(profile.picture()))
|
.child(Avatar::new(profile.name()).picture(profile.picture()))
|
||||||
.child(profile.name()),
|
.child(profile.name()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -2054,7 +2055,11 @@ impl BasePanel for RepoDetailView {
|
|||||||
|
|
||||||
impl Panel for RepoDetailView {
|
impl Panel for RepoDetailView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
repo_display_name(self.store.read(cx))
|
let store = self.store.read(cx);
|
||||||
|
let name = repo_display_name(store);
|
||||||
|
let avatar = repo_tab_avatar(store, cx);
|
||||||
|
|
||||||
|
tab_title(avatar, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ use gpui_component::{
|
|||||||
};
|
};
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||||
use signed_ui::{SegmentButton, UserAvatar};
|
use signed_ui::{Avatar, SegmentButton};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use super::open_repo_panel;
|
use super::{open_repo_panel, panel_avatar, tab_title};
|
||||||
|
|
||||||
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.;
|
||||||
@@ -289,7 +289,7 @@ impl RepoListView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(UserAvatar::new(owner.name()).picture(owner.picture()))
|
.child(Avatar::new(owner.name()).picture(owner.picture()))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
@@ -389,7 +389,7 @@ impl BasePanel for RepoListView {
|
|||||||
|
|
||||||
impl Panel for RepoListView {
|
impl Panel for RepoListView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().text_sm().child(SharedString::from("Explore"))
|
tab_title(panel_avatar("explore"), SharedString::from("Explore"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use gpui_component::spinner::Spinner;
|
|||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||||
use signed_state::RepoStore;
|
use signed_state::RepoStore;
|
||||||
|
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
pub struct SendPatchView {
|
pub struct SendPatchView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
@@ -205,8 +207,11 @@ impl BasePanel for SendPatchView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for SendPatchView {
|
impl Panel for SendPatchView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!("{}/send-patch", self.repo_name)))
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
|
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||||
|
|
||||||
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,15 +215,19 @@ pub fn load_user_grasp_servers(
|
|||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let (user, client) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(user) = user else {
|
||||||
state.update(cx, |state, _| state.loading_servers = false);
|
state.update(cx, |state, _| state.loading_servers = false);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let client = backend.read(cx).client();
|
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
let result = signed_state::user_grasp_list_servers(client, user).await;
|
let result = signed_state::user_grasp_list_servers(&client, user).await;
|
||||||
|
|
||||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use signed_state::{
|
|||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
||||||
ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos,
|
ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos,
|
||||||
};
|
};
|
||||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||||
|
|
||||||
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
||||||
|
|
||||||
@@ -79,16 +79,12 @@ impl SidebarPanel {
|
|||||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||||
|
|
||||||
if !signer_changed && !signer_required {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if signer_required {
|
if signer_required {
|
||||||
this.banner = pick_banner();
|
this.banner = pick_banner();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
if this.refresh(cx) || signer_required {
|
if this.refresh(cx) || signer_changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -131,16 +127,14 @@ impl SidebarPanel {
|
|||||||
let user = backend.read(cx).current_user();
|
let user = backend.read(cx).current_user();
|
||||||
|
|
||||||
let (announcements, local_repos, scanning) = {
|
let (announcements, local_repos, scanning) = {
|
||||||
let repo_list = RepoListStore::global(cx);
|
let repo_list = RepoListStore::global(cx).read(cx);
|
||||||
let repo_list = repo_list.read(cx);
|
|
||||||
|
|
||||||
let announcements = user
|
let announcements = user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|user| repo_list.announcements_of(user))
|
.map(|user| repo_list.announcements_of(user))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let local = LocalReposStore::global(cx);
|
let local = LocalReposStore::global(cx).read(cx);
|
||||||
let local = local.read(cx);
|
|
||||||
let local_repos =
|
let local_repos =
|
||||||
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
||||||
|
|
||||||
@@ -447,7 +441,9 @@ impl SidebarPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let name = announcement.name().map(SharedString::from);
|
let name = announcement.name().map(SharedString::from);
|
||||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
// The sidebar rows are compact, so the avatar matches the 16px nav icons.
|
||||||
|
let avatar =
|
||||||
|
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||||
let announcement = announcement.clone();
|
let announcement = announcement.clone();
|
||||||
|
|
||||||
let unpushed = self
|
let unpushed = self
|
||||||
@@ -552,7 +548,7 @@ impl SidebarPanel {
|
|||||||
Button::new("user").text().dropdown_caret(true).child(
|
Button::new("user").text().dropdown_caret(true).child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(name.clone()).picture(picture))
|
||||||
.child(div().text_xs().font_semibold().child(name)),
|
.child(div().text_xs().font_semibold().child(name)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -654,7 +650,8 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
|||||||
|
|
||||||
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
|
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
|
||||||
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
||||||
let avatar = PixelAvatar::new(entry.path.to_string_lossy());
|
// Matches the other sidebar rows, and the badge below sits at its corner.
|
||||||
|
let avatar = PixelAvatar::new(entry.path.to_string_lossy()).xsmall();
|
||||||
|
|
||||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||||
return avatar.into_any_element();
|
return avatar.into_any_element();
|
||||||
@@ -668,7 +665,7 @@ fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
|||||||
.child(avatar)
|
.child(avatar)
|
||||||
.child(
|
.child(
|
||||||
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
||||||
UserAvatar::new(profile.name())
|
Avatar::new(profile.name())
|
||||||
.picture(profile.picture())
|
.picture(profile.picture())
|
||||||
.size(px(14.)),
|
.size(px(14.)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||||
v_flex,
|
v_flex,
|
||||||
};
|
};
|
||||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||||
|
|
||||||
use super::{normalize_server, server_host};
|
use super::{normalize_server, server_host};
|
||||||
@@ -52,6 +52,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
|||||||
/// Created once when the dialog opens, so control state survives re-renders.
|
/// Created once when the dialog opens, so control state survives re-renders.
|
||||||
struct SettingsControls {
|
struct SettingsControls {
|
||||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
|
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
font_size: Entity<InputState>,
|
font_size: Entity<InputState>,
|
||||||
@@ -89,6 +90,23 @@ impl SettingsControls {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let event_fetching_options = vec![
|
||||||
|
SelectOption::new("curated", "Curated"),
|
||||||
|
SelectOption::new("uncensored", "Uncensored"),
|
||||||
|
];
|
||||||
|
let event_fetching_value = match settings.event_fetching {
|
||||||
|
EventFetchingStrategy::Curated => "curated",
|
||||||
|
EventFetchingStrategy::Uncensored => "uncensored",
|
||||||
|
};
|
||||||
|
let event_fetching = cx.new(|cx| {
|
||||||
|
SelectState::new(
|
||||||
|
event_fetching_options.clone(),
|
||||||
|
selected_index(&event_fetching_options, event_fetching_value),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
let (light_options, dark_options) = theme_options(cx);
|
let (light_options, dark_options) = theme_options(cx);
|
||||||
let light_theme = cx.new(|cx| {
|
let light_theme = cx.new(|cx| {
|
||||||
SelectState::new(
|
SelectState::new(
|
||||||
@@ -147,6 +165,19 @@ impl SettingsControls {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
subscriptions.push(cx.subscribe(&event_fetching, |_, event, cx| {
|
||||||
|
if let SelectEvent::Confirm(Some(value)) = event {
|
||||||
|
let strategy = match value.as_ref() {
|
||||||
|
"curated" => EventFetchingStrategy::Curated,
|
||||||
|
_ => EventFetchingStrategy::Uncensored,
|
||||||
|
};
|
||||||
|
let store = SettingsStore::global(cx);
|
||||||
|
store.update(cx, |store, cx| {
|
||||||
|
store.edit(|settings| settings.event_fetching = strategy, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||||
if let SelectEvent::Confirm(Some(value)) = event {
|
if let SelectEvent::Confirm(Some(value)) = event {
|
||||||
let store = SettingsStore::global(cx);
|
let store = SettingsStore::global(cx);
|
||||||
@@ -252,6 +283,7 @@ impl SettingsControls {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
appearance,
|
appearance,
|
||||||
|
event_fetching,
|
||||||
light_theme,
|
light_theme,
|
||||||
dark_theme,
|
dark_theme,
|
||||||
font_size,
|
font_size,
|
||||||
@@ -290,8 +322,12 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
|||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
.child(theme_section(&settings, controls, cx))
|
.child(theme_section(&settings, controls, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.child(tab_bar_section(&settings, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(grasp_servers_section(&settings, controls, cx))
|
.child(grasp_servers_section(&settings, controls, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.child(event_fetching_section(controls, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(repositories_section(&settings, controls, cx))
|
.child(repositories_section(&settings, controls, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +340,16 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which relays repository activity is fetched from.
|
||||||
|
fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||||
|
v_flex().w_full().gap_3().child(setting_row(
|
||||||
|
cx,
|
||||||
|
"Event Fetching Strategy",
|
||||||
|
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
|
||||||
|
Select::new(&controls.event_fetching).w_full(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
@@ -374,6 +420,26 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||||
|
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||||
|
v_flex().w_full().gap_3().child(setting_row(
|
||||||
|
cx,
|
||||||
|
"Hide Tab Navigation Buttons",
|
||||||
|
"Enable to show navigation buttons when more than 10 tabs.",
|
||||||
|
Switch::new("hide-tab-navigation")
|
||||||
|
.checked(settings.tab_bar.hide_navigation_buttons)
|
||||||
|
.on_click(move |checked: &bool, _window, cx| {
|
||||||
|
let store = SettingsStore::global(cx);
|
||||||
|
store.update(cx, |store, cx| {
|
||||||
|
store.edit(
|
||||||
|
|settings| settings.tab_bar.hide_navigation_buttons = *checked,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
|
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
|
||||||
fn grasp_servers_section(
|
fn grasp_servers_section(
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Signed"
|
|||||||
description = "Nostr Git client for browsing repositories and collaborating"
|
description = "Nostr Git client for browsing repositories and collaborating"
|
||||||
identifier = "info.reya.signed"
|
identifier = "info.reya.signed"
|
||||||
category = "DeveloperTool"
|
category = "DeveloperTool"
|
||||||
version = "0.1.0-alpha"
|
version = "0.2.0-alpha"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
Reference in New Issue
Block a user