remove workspace, dock and signed_ui crates
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
This commit is contained in:
Generated
+8
-8
@@ -3059,7 +3059,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-core"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"flume 0.12.0",
|
||||
@@ -3074,7 +3074,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-data"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui-native-core",
|
||||
@@ -3086,7 +3086,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-editor"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui-native-core",
|
||||
@@ -3105,7 +3105,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-icons"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
@@ -3114,7 +3114,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-kit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui-native-core",
|
||||
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-layout"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui-native-core",
|
||||
@@ -3141,7 +3141,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-theme"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3162,7 +3162,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-native-utils"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=9678a7676f4c70f91f04b6ab0ad68148659c2558#9678a7676f4c70f91f04b6ab0ad68148659c2558"
|
||||
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=6667c812597bd2adf415d29a4e50355bb3bf06ee#6667c812597bd2adf415d29a4e50355bb3bf06ee"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui-native-theme",
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@
|
||||
resolver = "2"
|
||||
members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
exclude = ["crates/dock", "crates/signed_ui", "crates/workspace"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
@@ -14,7 +13,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
|
||||
|
||||
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "9678a7676f4c70f91f04b6ab0ad68148659c2558", features = ["layout", "editor"] }
|
||||
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "6667c812597bd2adf415d29a4e50355bb3bf06ee", features = ["layout", "editor"] }
|
||||
|
||||
settings = { path = "crates/settings" }
|
||||
signed_app = { path = "crates/signed_app" }
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "dock"
|
||||
description = "The Signed dock skin over gpui-component's upstream dock (gpui_base::dock engine + renderer traits)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
settings = { path = "../settings" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
@@ -1,276 +0,0 @@
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref as _;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _,
|
||||
IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style,
|
||||
Styled as _, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||
PanelView, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::{HandleEdge, resize_handle};
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::invalid_panel::InvalidPanel;
|
||||
use crate::panel_handle;
|
||||
use crate::tab_panel::SignedTabGroupSkin;
|
||||
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
|
||||
impl SkinShared {
|
||||
pub(crate) fn area(&self) -> &WeakEntity<DockArea> {
|
||||
&self.area
|
||||
}
|
||||
|
||||
pub(crate) fn is_toggle_button_visible(&self) -> bool {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&self.resizing_dock
|
||||
}
|
||||
}
|
||||
|
||||
/// The Signed appearance for a [`DockArea`].
|
||||
/// Install it in the constructor, the only place the area's weak handle is available.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let dock = cx.new(|cx| {
|
||||
/// let skin = SignedDockSkin::new(cx);
|
||||
/// DockArea::new("dock", Some(1), window, cx).with_renderer(skin)
|
||||
/// });
|
||||
/// ```
|
||||
pub struct SignedDockSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
}
|
||||
|
||||
impl SignedDockSkin {
|
||||
pub fn new(cx: &mut Context<DockArea>) -> Rc<Self> {
|
||||
Rc::new(Self {
|
||||
shared: Rc::new(SkinShared {
|
||||
area: cx.weak_entity(),
|
||||
toggle_button_visible: Cell::new(true),
|
||||
resizing_dock: Cell::new(None),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
|
||||
&self.shared
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload a dock's resize handle drags.
|
||||
///
|
||||
/// It draws nothing, the handle element is the visible affordance.
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
impl Render for ResizePanel {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
impl DockAreaRenderer for SignedDockSkin {
|
||||
fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("dock-area")
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.flex()
|
||||
.flex_row()
|
||||
}
|
||||
|
||||
fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("dock-area-center")
|
||||
.flex()
|
||||
.flex_1()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
}
|
||||
|
||||
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
// `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
|
||||
div()
|
||||
.id(("dock-split-frame", node.as_u64()))
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.min_h(px(0.))
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.tab_bar)
|
||||
}
|
||||
|
||||
fn render_dock(
|
||||
&self,
|
||||
dock: &DockContext,
|
||||
content: AnyElement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(content)
|
||||
.child(self.render_resize_handle(dock, window, cx))
|
||||
.child(DockResizeTracker {
|
||||
dock: dock.clone(),
|
||||
shared: self.shared().clone(),
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Placeholder for a panel this build cannot construct.
|
||||
/// It dumps the state it was handed, so the layout survives a load and save.
|
||||
fn build_placeholder(
|
||||
&self,
|
||||
state: &PanelState,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Arc<dyn PanelView>> {
|
||||
let state = state.clone();
|
||||
Some(panel_handle(cx.new(|cx| {
|
||||
InvalidPanel::new(state.panel_name.clone(), state, cx)
|
||||
})))
|
||||
}
|
||||
|
||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SignedDockSkin {
|
||||
fn render_resize_handle(
|
||||
&self,
|
||||
dock: &DockContext,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let placement = dock.placement();
|
||||
let shared = self.shared().clone();
|
||||
|
||||
resize_handle("resize-handle", placement.axis())
|
||||
.when(placement.is_left(), |this| {
|
||||
this.inside(HandleEdge::Trailing)
|
||||
})
|
||||
.when(placement.is_right() || placement.is_bottom(), |this| {
|
||||
this.inside(HandleEdge::Leading)
|
||||
})
|
||||
.on_drag(ResizePanel, move |info, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
shared.resizing_dock().set(Some(placement));
|
||||
cx.new(|_| info.deref().clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the window's mouse stream into dock resizing.
|
||||
/// It draws nothing, the `paint` hook is the only window listener registration point.
|
||||
struct DockResizeTracker {
|
||||
dock: DockContext,
|
||||
shared: Rc<SkinShared>,
|
||||
}
|
||||
|
||||
impl IntoElement for DockResizeTracker {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DockResizeTracker {
|
||||
type PrepaintState = ();
|
||||
type RequestLayoutState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
(window.request_layout(Style::default(), None, cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
_: &mut App,
|
||||
) {
|
||||
let placement = self.dock.placement();
|
||||
|
||||
window.on_mouse_event({
|
||||
let dock = self.dock.clone();
|
||||
let shared = self.shared.clone();
|
||||
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||
return;
|
||||
}
|
||||
// Dragging a closed dock's handle reopens it.
|
||||
// Read the live state, the snapshot in `dock` would toggle it shut again.
|
||||
let open = shared
|
||||
.area()
|
||||
.upgrade()
|
||||
.is_some_and(|area| area.read(cx).is_dock_open(placement));
|
||||
if !open {
|
||||
dock.toggle(window, cx);
|
||||
}
|
||||
dock.resize_to(event.position, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let shared = self.shared.clone();
|
||||
move |_: &MouseUpEvent, phase, _, cx| {
|
||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||
return;
|
||||
}
|
||||
shared.resizing_dock().set(None);
|
||||
// The size lives on the dock, not the layout tree.
|
||||
// Nothing else tells a subscriber to persist it.
|
||||
_ = shared
|
||||
.area()
|
||||
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use gpui::{
|
||||
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
use gpui_base::dock::{PanelEvent, PanelState};
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Panel;
|
||||
|
||||
/// Stands in for a panel this build cannot construct.
|
||||
/// It returns the state it was handed, so the layout survives a load and save.
|
||||
pub(crate) struct InvalidPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
old_state: PanelState,
|
||||
}
|
||||
|
||||
impl InvalidPanel {
|
||||
pub(crate) fn new(
|
||||
name: impl Into<SharedString>,
|
||||
state: PanelState,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
name: name.into(),
|
||||
old_state: state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui_base::dock::Panel for InvalidPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"InvalidPanel"
|
||||
}
|
||||
|
||||
fn dump(&self, _: &App) -> PanelState {
|
||||
self.old_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for InvalidPanel {}
|
||||
|
||||
impl EventEmitter<PanelEvent> for InvalidPanel {}
|
||||
|
||||
impl Focusable for InvalidPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InvalidPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.my_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!(
|
||||
"The `{}` panel type is not registered in PanelRegistry.",
|
||||
self.name.clone()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{Context, Pixels, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
mod tab_panel;
|
||||
mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
pub use gpui_component::dock::{
|
||||
BasePanel, DockArea, DockEvent, DockLayout, DockPlacement, Panel, PanelEvent, panel_handle,
|
||||
};
|
||||
|
||||
/// Add an already-wrapped panel handle to the center of `area`.
|
||||
///
|
||||
/// Every panel entry point opens its panel there.
|
||||
pub fn add_center_panel(
|
||||
area: &mut DockArea,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
||||
}
|
||||
|
||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||
|
||||
/// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
|
||||
pub(crate) fn t(key: &'static str) -> &'static str {
|
||||
match key {
|
||||
"Dock.Close" => "Close",
|
||||
"Dock.Zoom In" => "Zoom In",
|
||||
"Dock.Zoom Out" => "Zoom Out",
|
||||
"Dock.Collapse" => "Collapse",
|
||||
"Dock.Expand" => "Expand",
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
@@ -1,963 +0,0 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
||||
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
||||
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled as _, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef,
|
||||
PanelView as BasePanelView, TabGroupContext, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
use settings::{SettingsStore, TabBarSettings};
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||
|
||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
|
||||
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
|
||||
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
|
||||
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
|
||||
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
|
||||
|
||||
/// Whether a group's prev/next buttons should be drawn.
|
||||
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
|
||||
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
|
||||
}
|
||||
|
||||
/// A panel's title, or its registered name when the panel has no handle.
|
||||
pub(crate) fn panel_title(
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
match PanelHandle::of(panel) {
|
||||
Some(handle) => handle.title(window, cx),
|
||||
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The preview that follows the cursor while a panel is dragged.
|
||||
struct DragPanelPreview {
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
}
|
||||
|
||||
impl Render for DragPanelPreview {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id("drag-panel")
|
||||
.cursor_grab()
|
||||
.p_1()
|
||||
.whitespace_nowrap()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tokens.tab_active)
|
||||
.opacity(0.75)
|
||||
.child(panel_title(&self.panel, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom affordance `panel` offers, if any.
|
||||
///
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||
panel
|
||||
.zoomable(cx)
|
||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The left-most, top-most tab group in a container.
|
||||
///
|
||||
/// A left dock's collapse button lives in this group.
|
||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-most, top-most tab group.
|
||||
///
|
||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
PaneRef::Split { axis, children, .. } => match axis {
|
||||
gpui::Axis::Vertical => children.first(),
|
||||
gpui::Axis::Horizontal => children.last(),
|
||||
}
|
||||
.and_then(right_top_group),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// One tab group's appearance, built once per container so its geometry is its own.
|
||||
pub(crate) struct SignedTabGroupSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
/// The tab shown last frame, so a change scrolls the new one into view.
|
||||
last_active_ix: Cell<Option<usize>>,
|
||||
/// Bounds of the title bar row, measured to place the title-bar drag overlay.
|
||||
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the empty strip after the last tab, where the drag region starts.
|
||||
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the suffix area, where the drag region ends.
|
||||
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
impl SignedTabGroupSkin {
|
||||
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
|
||||
Self {
|
||||
shared,
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
last_active_ix: Cell::new(None),
|
||||
title_bar_bounds: Rc::new(Cell::new(None)),
|
||||
title_bar_strip_bounds: Rc::new(Cell::new(None)),
|
||||
title_bar_suffix_bounds: Rc::new(Cell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A group that is the left dock's only group, with one panel, draws no chrome.
|
||||
/// The vendored dock rendered such a panel bare and the sidebar is one.
|
||||
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
|
||||
let Some(area) = self.shared.area().upgrade() else {
|
||||
return false;
|
||||
};
|
||||
let area = area.read(cx);
|
||||
let Some(left) = area
|
||||
.layout(DockPlacement::Left)
|
||||
.map(|tree| tree.root().id())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
left == group.node() && group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||
///
|
||||
/// A locked group never is, a bottom or right dock root always is.
|
||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
}
|
||||
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
|
||||
return None;
|
||||
}
|
||||
group.drag_panel(ix, cx)
|
||||
}
|
||||
|
||||
/// A dock's collapse button for this group's bar, or `None` when it does not belong.
|
||||
/// The icon direction depends on whether the dock is open.
|
||||
fn dock_toggle_button(
|
||||
&self,
|
||||
placement: DockPlacement,
|
||||
group: &TabGroupContext,
|
||||
cx: &mut App,
|
||||
) -> Option<Button> {
|
||||
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
// A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
|
||||
if !area.is_dock_collapsible(placement) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let designated = match placement {
|
||||
DockPlacement::Left => area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Right => area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| right_top_group(tree.root())),
|
||||
DockPlacement::Bottom => area
|
||||
.layout(DockPlacement::Bottom)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Center => return None,
|
||||
};
|
||||
if designated != Some(group.node()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_open = area.is_dock_open(placement);
|
||||
let icon = match (placement, is_open) {
|
||||
(DockPlacement::Left, true) => IconName::PanelLeft,
|
||||
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
|
||||
(DockPlacement::Right, true) => IconName::PanelRight,
|
||||
(DockPlacement::Right, false) => IconName::PanelRightOpen,
|
||||
(DockPlacement::Bottom, true) => IconName::PanelBottom,
|
||||
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
|
||||
(DockPlacement::Center, _) => return None,
|
||||
};
|
||||
|
||||
let area = self.shared.area().clone();
|
||||
Some(
|
||||
Button::new(SharedString::from(format!("toggle-dock:{placement:?}")))
|
||||
.icon(icon)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip(match is_open {
|
||||
true => t("Dock.Collapse"),
|
||||
false => t("Dock.Expand"),
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
_ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||
///
|
||||
/// Hidden by default, and only offered for crowded strips where the active
|
||||
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
||||
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let panels_len = group.panels().len();
|
||||
|
||||
let hidden = SettingsStore::try_global(cx)
|
||||
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
|
||||
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
|
||||
|
||||
if !navigation_buttons_visible(hidden, panels_len) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let prev_enabled = !collapsed && active_ix > 0;
|
||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("tab:prev")
|
||||
.icon(IconName::ArrowLeft)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip("Previous tab")
|
||||
.disabled(!prev_enabled)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
move |_, window, cx| group.select_tab(active_ix - 1, window, cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("tab:next")
|
||||
.icon(IconName::ArrowRight)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip("Next tab")
|
||||
.disabled(!next_enabled)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One tab of the pill strip.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
ix: usize,
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
active: bool,
|
||||
is_bottom_dock: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Tab {
|
||||
let collapsed = group.is_collapsed();
|
||||
let droppable = group.is_droppable();
|
||||
let drag = self.tab_drag(group, ix, cx);
|
||||
let handle = PanelHandle::of(&panel);
|
||||
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
||||
|
||||
let closable = !collapsed
|
||||
&& (group.is_closable()
|
||||
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
||||
|
||||
// Whether a double-click zooms this tab in.
|
||||
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
||||
|
||||
Tab::new(ix)
|
||||
.group(TAB_HOVER_GROUP)
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_7()
|
||||
.min_w_40()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
}),
|
||||
)
|
||||
.when(closable, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
||||
})
|
||||
.when(!active, |this| {
|
||||
this.invisible()
|
||||
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
||||
})
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip(t("Dock.Close"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let shared = self.shared.clone();
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
close_panel(&shared, &group, &panel, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
.text_color(cx.theme().tab_active_foreground)
|
||||
.bg(cx.theme().tab_active)
|
||||
})
|
||||
})
|
||||
.hover(|this| {
|
||||
if active {
|
||||
this
|
||||
} else {
|
||||
this.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tab)
|
||||
}
|
||||
})
|
||||
.selected(active)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let area = self.shared.area().clone();
|
||||
move |event, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||
if is_bottom_dock && collapsed {
|
||||
_ = area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A double-click zooms the tab's panel
|
||||
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.when(!collapsed, |this| {
|
||||
this.when_some(drag, |this, drag| {
|
||||
this.on_drag(drag, {
|
||||
let panel = panel.clone();
|
||||
move |drag, offset, _, cx| {
|
||||
cx.stop_propagation();
|
||||
drag.set_drag_offset(offset);
|
||||
drag.set_preview_size(DRAG_PREVIEW_SIZE);
|
||||
cx.new(|_| DragPanelPreview {
|
||||
panel: panel.clone(),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
|
||||
}
|
||||
})
|
||||
.drag_over::<AnyDrag>(|this, _, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |item: &AnyDrag, window, cx| {
|
||||
group.drop_item(item.clone(), None, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The strip after the last tab, a drop target for panels and other drag items.
|
||||
/// Its left edge marks where the title-bar drag overlay starts.
|
||||
fn render_empty_space(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
tabs_count: usize,
|
||||
_cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let strip_bounds = self.title_bar_strip_bounds.clone();
|
||||
let shared = self.shared.clone();
|
||||
let droppable = group.is_droppable();
|
||||
|
||||
let mut empty = div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if strip_bounds.get() != Some(bounds) {
|
||||
strip_bounds.set(Some(bounds));
|
||||
_ = shared.area().update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
});
|
||||
|
||||
if droppable {
|
||||
empty = empty
|
||||
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
let node = group.node();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
// A panel dropped past its own last tab lands in the final slot.
|
||||
// A panel from elsewhere is appended in the background.
|
||||
let ix = (drag.source() == node).then(|| tabs_count - 1);
|
||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||
}
|
||||
})
|
||||
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |item: &AnyDrag, window, cx| {
|
||||
group.drop_item(item.clone(), None, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
empty.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let control = group
|
||||
.active_panel()
|
||||
.and_then(|panel| panel_zoom_control(panel, cx));
|
||||
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||
v_flex()
|
||||
.id("tab-panel")
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.on_action({
|
||||
let group = group.clone();
|
||||
let shared = shared.clone();
|
||||
move |_: &ClosePanel, window, cx| {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
close_panel(&shared, &group, panel, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id("active-panel")
|
||||
// A collapsed group draws its tab strip only, so the content claims no space.
|
||||
.when(!group.is_collapsed(), |this| this.flex_1())
|
||||
}
|
||||
|
||||
fn render_tab_bar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// An emptied group draws no tab bar, the app prunes the emptied dock later.
|
||||
if group.panels().is_empty() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
// The sidebar group draws no chrome, like the vendored `DockItem::Panel`.
|
||||
if self.is_plain_sidebar_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let tabs_count = group.panels().len();
|
||||
|
||||
let left_dock_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
|
||||
let bottom_dock_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||
// tab bar under them reserves the space. That is the center's top-left group
|
||||
// when the left dock is closed or absent, and the zoomed group otherwise:
|
||||
// a zoom fills the whole area, hiding the docks the frame would draw.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
match area.zoomed_group() {
|
||||
Some(zoomed) => zoomed == group.node(),
|
||||
None => {
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view.
|
||||
// The group owns selection, so the skin watches for the change itself.
|
||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||
let visible: Vec<usize> = group
|
||||
.panels()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, panel)| panel.visible(cx))
|
||||
.map(|(ix, _)| ix)
|
||||
.collect();
|
||||
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
|
||||
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
|
||||
{
|
||||
self.scroll_handle.scroll_to_item(visible_ix);
|
||||
}
|
||||
|
||||
// The tab strip ends at the last tab, the bar has no element after it.
|
||||
// Cover that dead zone with an overlay so it can drag the window.
|
||||
let drag_overlay = match (
|
||||
self.title_bar_bounds.get(),
|
||||
self.title_bar_strip_bounds.get(),
|
||||
self.title_bar_suffix_bounds.get(),
|
||||
) {
|
||||
(Some(title), Some(strip), Some(suffix)) => {
|
||||
let left = strip.left() - title.left();
|
||||
let right = title.right() - suffix.left();
|
||||
(left + right < title.size.width).then(|| {
|
||||
title_bar_drag_handlers(
|
||||
div()
|
||||
.id(("title-bar-drag", group.node().as_u64()))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.left(left)
|
||||
.right(right),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let tabs: Vec<_> = group
|
||||
.panels()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, panel)| {
|
||||
let mut active = displayed == Some(panel.panel_id(cx));
|
||||
if !panel.visible(cx) {
|
||||
return None;
|
||||
}
|
||||
// Collapsed tabs never show as active, the strip only reopens the dock.
|
||||
if collapsed {
|
||||
active = false;
|
||||
}
|
||||
Some(self.render_tab(group, ix, panel.clone(), active, is_bottom_dock, window, cx))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let empty_space = self.render_empty_space(group, tabs_count, cx);
|
||||
let title_bar_bounds = self.title_bar_bounds.clone();
|
||||
let title_bar_suffix_bounds = self.title_bar_suffix_bounds.clone();
|
||||
let shared = self.shared.clone();
|
||||
let suffix_shared = self.shared.clone();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if title_bar_bounds.get() != Some(bounds) {
|
||||
title_bar_bounds.set(Some(bounds));
|
||||
_ = shared.area().update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Tabs::new("tab-bar")
|
||||
.px(px(-1.))
|
||||
// A collapsed group stands in for a closed bottom dock,
|
||||
// which base gives only its reserved strip. The bar keeps
|
||||
// its own height there rather than the title-bar height,
|
||||
// so it fits instead of being clipped away.
|
||||
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
||||
// Only a collapsed (bottom) dock tags its bar, so the
|
||||
// selector is unique when a test measures it.
|
||||
.when(collapsed, |this| {
|
||||
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// -1 px so the border does not overlap the first tab.
|
||||
.right(-px(1.))
|
||||
.h_full()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button)
|
||||
.child(self.render_prev_next_tab_buttons(group, cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex().id("tabs").flex_1().overflow_x_hidden().child(
|
||||
h_flex()
|
||||
.id("tabs-inner")
|
||||
.relative()
|
||||
.gap(px(4.))
|
||||
.overflow_x_scroll()
|
||||
.lock_scroll_axis()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.children(tabs)
|
||||
.when(!collapsed, |this| this.child(empty_space)),
|
||||
),
|
||||
)
|
||||
.when(!collapsed, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.h_full()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if title_bar_suffix_bounds.get() != Some(bounds) {
|
||||
title_bar_suffix_bounds.set(Some(bounds));
|
||||
_ = suffix_shared
|
||||
.area()
|
||||
.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.children(
|
||||
group
|
||||
.active_panel()
|
||||
.and_then(PanelHandle::of)
|
||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||
)
|
||||
.children(right_dock_button),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
|
||||
)
|
||||
.child(window_controls::window_controls(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_active_panel(
|
||||
&self,
|
||||
panel: AnyView,
|
||||
group: &TabGroupContext,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> AnyElement {
|
||||
if group.is_collapsed() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.id("tab-content")
|
||||
.overflow_y_scroll()
|
||||
.overflow_x_hidden()
|
||||
.flex_1()
|
||||
.child(panel.cached(StyleRefinement::default().absolute().size_full()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_drop_indicator(
|
||||
&self,
|
||||
indicator: DropIndicator,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let (from, to) = (indicator.from(), indicator.to());
|
||||
// The element sits at the drop target, the animation walks back from the source.
|
||||
let offset = from.origin() - to.origin();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left(to.origin().x)
|
||||
.top(to.origin().y)
|
||||
.w(to.size().width)
|
||||
.h(to.size().height)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bg(cx.theme().tokens.drop_target)
|
||||
.with_animation(
|
||||
gpui::ElementId::NamedInteger(
|
||||
"drop-placeholder".into(),
|
||||
indicator.epoch(),
|
||||
),
|
||||
Animation::new(Duration::from_millis(150)).with_easing(ease_out_cubic),
|
||||
move |this, delta| {
|
||||
let origin = offset.lerp(&Point::default(), delta);
|
||||
let width = from.size().width.lerp(&to.size().width, delta);
|
||||
let height = from.size().height.lerp(&to.size().height, delta);
|
||||
this.left(origin.x).top(origin.y).w(width).h(height)
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,168 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
|
||||
|
||||
use crate::TAB_BAR_HEIGHT;
|
||||
|
||||
const CONTROL_WIDTH: f32 = 34.;
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
enum ControlIcon {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl ControlIcon {
|
||||
fn minimize() -> Self {
|
||||
Self::Minimize
|
||||
}
|
||||
|
||||
fn restore() -> Self {
|
||||
Self::Restore
|
||||
}
|
||||
|
||||
fn maximize() -> Self {
|
||||
Self::Maximize
|
||||
}
|
||||
|
||||
fn close() -> Self {
|
||||
Self::Close
|
||||
}
|
||||
|
||||
fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "minimize",
|
||||
Self::Restore => "restore",
|
||||
Self::Maximize => "maximize",
|
||||
Self::Close => "close",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Minimize => IconName::WindowMinimize,
|
||||
Self::Restore => IconName::WindowRestore,
|
||||
Self::Maximize => IconName::WindowMaximize,
|
||||
Self::Close => IconName::WindowClose,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_control_area(&self) -> gpui::WindowControlArea {
|
||||
match self {
|
||||
Self::Minimize => gpui::WindowControlArea::Min,
|
||||
Self::Restore | Self::Maximize => gpui::WindowControlArea::Max,
|
||||
Self::Close => gpui::WindowControlArea::Close,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_close(&self) -> bool {
|
||||
matches!(self, Self::Close)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_fg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_foreground
|
||||
} else {
|
||||
cx.theme().secondary_foreground
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_bg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().secondary_hover
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn active_bg(&self, cx: &mut App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_active
|
||||
} else {
|
||||
cx.theme().secondary_active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ControlIcon {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
let hover_fg = self.hover_fg(cx);
|
||||
let hover_bg = self.hover_bg(cx);
|
||||
let active_bg = self.active_bg(cx);
|
||||
let icon = self.clone();
|
||||
|
||||
div()
|
||||
.id(self.id())
|
||||
.flex()
|
||||
.w(px(CONTROL_WIDTH))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.content_center()
|
||||
.items_center()
|
||||
.text_color(cx.theme().foreground)
|
||||
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
|
||||
.active(|style| style.bg(active_bg).text_color(hover_fg))
|
||||
.when(is_windows, |this| {
|
||||
this.window_control_area(self.window_control_area())
|
||||
})
|
||||
.when(is_linux, |this| {
|
||||
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
match icon {
|
||||
Self::Minimize => window.minimize_window(),
|
||||
Self::Restore | Self::Maximize => window.zoom_window(),
|
||||
Self::Close => window.remove_window(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(Icon::new(self.icon()).small())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
|
||||
return div().id("window-controls");
|
||||
}
|
||||
|
||||
let supported = window.window_controls();
|
||||
|
||||
h_flex()
|
||||
.id("window-controls")
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
// The controls span the title bar but never grow past the tab bar height.
|
||||
.when(cfg!(target_os = "windows"), |this| {
|
||||
this.max_h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
.border_l_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tokens.tab_bar)
|
||||
.when(supported.minimize, |this| {
|
||||
this.child(ControlIcon::minimize())
|
||||
})
|
||||
.when(supported.maximize, |this| {
|
||||
this.child(if window.is_maximized() {
|
||||
ControlIcon::restore()
|
||||
} else {
|
||||
ControlIcon::maximize()
|
||||
})
|
||||
})
|
||||
.child(ControlIcon::close())
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
|
||||
TestAppContext, Window,
|
||||
};
|
||||
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_first_frame_renders_the_area_and_its_docks(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)
|
||||
});
|
||||
|
||||
let bottom = cx.update(|_, cx| cx.new(Probe::new));
|
||||
cx.update(|window, cx| {
|
||||
let left = cx.new(Probe::new);
|
||||
let center = cx.new(Probe::new);
|
||||
|
||||
area.update(cx, |area, cx| {
|
||||
area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(left), cx),
|
||||
window,
|
||||
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.clone()), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The first frame walks every render hook, all of which read the dock area.
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// Emptying a dock leaves an empty group, its render must also be safe.
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.remove_panel(bottom, window, cx);
|
||||
});
|
||||
});
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use gpui::{
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::input::InputState;
|
||||
use gpui_native_kit::scroll_area::ScrollArea;
|
||||
use gpui_native_kit::separator::Separator;
|
||||
use helpers::{
|
||||
appearance_select, default_folder_selector, event_fetching_select, grasp_servers_editor,
|
||||
@@ -55,6 +54,7 @@ impl Render for SettingsDialog {
|
||||
Dialog::new("settings-dialog")
|
||||
.open(true)
|
||||
.min_width(px(650.))
|
||||
.height(px(560.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
crate::shared::dialogs::close_global(cx);
|
||||
@@ -62,9 +62,6 @@ impl Render for SettingsDialog {
|
||||
})
|
||||
.title("Settings")
|
||||
.content(move |_, cx| {
|
||||
ScrollArea::new("settings-scroll")
|
||||
.max_h(px(560.))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(setting_row(
|
||||
@@ -99,8 +96,7 @@ impl Render for SettingsDialog {
|
||||
"Default Folder",
|
||||
"The folder for newly created repositories.",
|
||||
default_folder_selector(&default_folder, weak.clone()),
|
||||
)),
|
||||
)
|
||||
))
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "signed_ui"
|
||||
description = "Reusable UI components and elements for Signed, built on gpui-base and gpui-component."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-component.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
@@ -1,252 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Pixels, SharedString, StyleRefinement, Window, div, px};
|
||||
use gpui_base::{AvatarImage, StyledExt};
|
||||
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||
|
||||
/// Number of rows and columns in the pixel grid.
|
||||
const GRID_SIZE: usize = 8;
|
||||
/// Empty cells kept between the pattern and the avatar edge.
|
||||
const MARGIN: usize = 1;
|
||||
/// Probability that a cell in the left half of the pattern area is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells.
|
||||
const MIN_FILLED: usize = 5;
|
||||
|
||||
/// An avatar for a user or other entity.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
id: SharedString,
|
||||
name: Option<SharedString>,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Create an avatar for `id`, a public key or any other stable string.
|
||||
///
|
||||
/// The id seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new<T>(id: T) -> Self
|
||||
where
|
||||
T: Display,
|
||||
{
|
||||
Self {
|
||||
id: id.to_string().into(),
|
||||
name: None,
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display name of the avatar's subject, if known.
|
||||
pub fn name<T>(mut self, name: T) -> Self
|
||||
where
|
||||
T: Into<Option<SharedString>>,
|
||||
{
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture<T>(mut self, picture: Option<T>) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Avatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Avatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let radius = cx.theme().radius;
|
||||
|
||||
match self.picture {
|
||||
Some(url) => div()
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(radius)
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().secondary)
|
||||
.child(AvatarImage::new(url).size_full().rounded(radius))
|
||||
.refine_style(&self.style)
|
||||
.into_any_element(),
|
||||
None => PixelAvatar {
|
||||
seed: fnv1a(self.id.as_bytes()),
|
||||
side: side_length(self.size),
|
||||
style: self.style,
|
||||
}
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
#[derive(IntoElement)]
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
side: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let pattern = pattern(self.seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
let hue = self.seed as f32 / u64::MAX as f32;
|
||||
let main = theme.blue.hue(hue);
|
||||
|
||||
let shade = if theme.is_dark() {
|
||||
main.lightness((main.l * 1.6).min(0.95))
|
||||
} else {
|
||||
main.lightness((main.l * 0.45).max(0.18))
|
||||
};
|
||||
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
let value = pattern[row * GRID_SIZE + col];
|
||||
if value != 0 {
|
||||
let color = if value == 2 { shade } else { main };
|
||||
cells.push(
|
||||
div()
|
||||
.row_start(row as i16 + 1)
|
||||
.row_end(row as i16 + 2)
|
||||
.col_start(col as i16 + 1)
|
||||
.col_end(col as i16 + 2)
|
||||
.bg(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(self.side)
|
||||
.flex_shrink_0()
|
||||
.rounded(theme.radius)
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
.children(cells)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`.
|
||||
fn side_length(size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => px(16.),
|
||||
Size::Small => px(24.),
|
||||
Size::Medium => px(48.),
|
||||
Size::Large => px(80.),
|
||||
Size::Size(size) => size,
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
let mut filled = 0usize;
|
||||
|
||||
// Only the inner rows and the inner left half are candidates; mirroring
|
||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||
|
||||
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||
for col in MARGIN..GRID_SIZE / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled < MIN_FILLED {
|
||||
let total = art_rows * art_columns;
|
||||
let start = (rng.next() % total as u64) as usize;
|
||||
|
||||
for offset in 0..total {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * GRID_SIZE + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Fill `cell (row, col)` and its horizontal mirror.
|
||||
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
||||
pattern[row * GRID_SIZE + col] = value;
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||
struct PixelRng(u64);
|
||||
|
||||
impl PixelRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
|
||||
fn chance(&mut self, probability: f32) -> bool {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClipboardItem, Div, ElementId, SharedString, div};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||
|
||||
/// A muted command row with a copy button.
|
||||
pub fn copy_row<E, T>(copy_id: E, command: T, cx: &App) -> Div
|
||||
where
|
||||
E: Into<ElementId>,
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let command = command.into();
|
||||
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_xs()
|
||||
.child(command.clone()),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(copy_id)
|
||||
.tooltip("Copy")
|
||||
.value(command.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// One row of a copy menu, with a small title above the compact label.
|
||||
pub fn menu_copy_row(
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
label: String,
|
||||
copy: String,
|
||||
) -> PopupMenuItem {
|
||||
let row_copy = copy.clone();
|
||||
PopupMenuItem::element(move |_window, _cx| {
|
||||
let button_copy = copy.clone();
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().flex_shrink_0().w_20().font_semibold().child(title))
|
||||
.child(div().flex_1().text_ellipsis().child(label.clone())),
|
||||
)
|
||||
.child(Clipboard::new(id).tooltip("Copy").value(button_copy))
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone()));
|
||||
})
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
Anchor, AnyElement, App, DismissEvent, ElementId, Entity, Focusable, SharedString,
|
||||
StyleRefinement, Window, px,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// A split dropdown button built on `gpui_base::Popover`.
|
||||
/// An action element next to a caret that opens a [`PopupMenu`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
|
||||
impl DropdownButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The action half of the button.
|
||||
/// It keeps its own icon, label, tooltip and click handler.
|
||||
pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
|
||||
self.action = Some(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The menu built by `builder`.
|
||||
/// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
|
||||
/// Existing menu code keeps working.
|
||||
pub fn dropdown_menu(
|
||||
mut self,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.menu = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for DropdownButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the [`PopupMenu`] entity of one popover between renders.
|
||||
/// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
|
||||
#[derive(Default)]
|
||||
struct DropdownMenuState {
|
||||
menu: Option<Entity<PopupMenu>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for DropdownButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
debug_assert!(
|
||||
self.menu.is_some(),
|
||||
"a DropdownButton needs a `dropdown_menu`"
|
||||
);
|
||||
|
||||
// The popover needs its own id.
|
||||
// The container and the popover both register keyed state on this window.
|
||||
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
||||
let anchor = self.anchor;
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
let caret_id = popover_id.clone();
|
||||
this.child(
|
||||
Popover::new(popover_id)
|
||||
.anchor(anchor)
|
||||
// The menu dismisses itself on outside click or Escape.
|
||||
// The subscription below closes the popover along with it.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(move |is_open, _, cx| {
|
||||
let caret = default_caret(caret_id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
menu_state
|
||||
.update(cx, |state, _| state.menu = Some(menu.clone()));
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The default caret, a chevron button the height of a medium button.
|
||||
/// It is tinted by the theme and styled for hover and menu-open states.
|
||||
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
BaseButton::new(id)
|
||||
.h(px(32.))
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|style| style.bg(cx.theme().secondary_hover))
|
||||
.styles(|this| {
|
||||
this.selected(|style| style.bg(cx.theme().secondary_active))
|
||||
.disabled(|style| style.opacity(0.5))
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
mod avatar;
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod placeholder;
|
||||
mod ref_selector;
|
||||
mod segment_button;
|
||||
mod setting;
|
||||
mod status_badge;
|
||||
mod title_bar;
|
||||
mod tree_row;
|
||||
|
||||
pub mod copy_row;
|
||||
|
||||
pub use avatar::Avatar;
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use nav_item::NavItem;
|
||||
pub use placeholder::placeholder;
|
||||
pub use ref_selector::ref_selector_trigger;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
pub use setting::{SelectOption, setting_block, setting_row};
|
||||
pub use status_badge::status_badge;
|
||||
pub use title_bar::title_bar_drag_handlers;
|
||||
pub use tree_row::tree_row;
|
||||
@@ -1,78 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||
|
||||
/// A single navigation entry in a sidebar.
|
||||
/// It has an arbitrary leading element, such as an icon or avatar, and a text label.
|
||||
/// Hover highlights the row.
|
||||
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct NavItem {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: gpui::AnyElement,
|
||||
label: SharedString,
|
||||
/// Trailing element at the right edge of the row, after the ellipsized label.
|
||||
suffix: Option<gpui::AnyElement>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
pub fn new<I, L, N>(id: I, label: L, icon: N) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
N: IntoElement,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon: icon.into_any_element(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NavItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(self.icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(self.label),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().flex_shrink_0().child(suffix))
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.when_some(self.on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, div};
|
||||
use gpui_component::{ActiveTheme, v_flex};
|
||||
|
||||
/// A centered muted placeholder message, filling its parent.
|
||||
pub fn placeholder(message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(message.to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
|
||||
/// The kind icon, the selection or placeholder, and the caret. `Combobox`
|
||||
/// replaces its default trigger entirely, the only way to show an icon inside it.
|
||||
pub fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div, px, relative};
|
||||
use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// A small count badge shown after a label.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CountBadge {
|
||||
count: usize,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl CountBadge {
|
||||
pub fn new(count: usize) -> Self {
|
||||
Self {
|
||||
count,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for CountBadge {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CountBadge {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(self.count.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
|
||||
/// The selected state renders the button pressed, styled from theme button tokens.
|
||||
/// Built on the unstyled `gpui_base::Button`, like the app's other custom controls.
|
||||
/// The `primary` variant uses the primary button tokens.
|
||||
/// It suits call-to-action buttons such as `New issue` and `New PR`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct SegmentButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: Option<gpui::AnyElement>,
|
||||
label: SharedString,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
primary: bool,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl SegmentButton {
|
||||
pub fn new<I, L>(id: I, label: L) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
icon: None,
|
||||
count: None,
|
||||
selected: false,
|
||||
primary: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading icon, e.g. `Icon::new(CustomIconName::GitIssueDone)`.
|
||||
pub fn icon(mut self, icon: impl IntoElement) -> Self {
|
||||
self.icon = Some(icon.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// A count shown in a badge after the label.
|
||||
pub fn count(mut self, count: usize) -> Self {
|
||||
self.count = Some(count);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the button reflects an active filter/tab.
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the primary button tokens, for call-to-action buttons.
|
||||
pub fn primary(mut self) -> Self {
|
||||
self.primary = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for SegmentButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SegmentButton {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
selected,
|
||||
primary,
|
||||
on_click,
|
||||
} = self;
|
||||
|
||||
let theme = cx.theme();
|
||||
let fg = if primary {
|
||||
theme.button_primary_foreground
|
||||
} else {
|
||||
theme.button_foreground
|
||||
};
|
||||
let base = if primary {
|
||||
theme.button_primary
|
||||
} else {
|
||||
theme.button_active
|
||||
};
|
||||
let hover = if primary {
|
||||
theme.button_primary_hover
|
||||
} else {
|
||||
theme.button_hover
|
||||
};
|
||||
let active = if primary {
|
||||
theme.button_primary_active
|
||||
} else {
|
||||
theme.button_active
|
||||
};
|
||||
|
||||
BaseButton::new(id)
|
||||
.refine_style(&style)
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.when_some(icon, |this, icon| this.child(icon))
|
||||
.child(div().text_sm().child(label))
|
||||
.when_some(count, |this, count| this.child(CountBadge::new(count)))
|
||||
.text_color(fg)
|
||||
.rounded(theme.radius)
|
||||
.hover(move |this| this.bg(hover))
|
||||
.active(move |this| this.bg(active))
|
||||
.selected(selected)
|
||||
.when(primary, |this| this.bg(base))
|
||||
.when(selected, |this| this.bg(active))
|
||||
.when_some(on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, div};
|
||||
use gpui_component::searchable_list::SearchableListItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// A dropdown option with a display label and a stored value.
|
||||
#[derive(Clone)]
|
||||
pub struct SelectOption {
|
||||
value: SharedString,
|
||||
label: SharedString,
|
||||
}
|
||||
|
||||
impl SelectOption {
|
||||
/// Create an option with the given stored `value` and display `label`.
|
||||
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &SharedString {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &SharedString {
|
||||
&self.label
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchableListItem for SelectOption {
|
||||
type Value = SharedString;
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings row with the label and description on the left and the control on the right.
|
||||
pub fn setting_row(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
) -> impl IntoElement {
|
||||
let title = title.into();
|
||||
let description = description.into();
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(div().text_sm().font_semibold().child(title))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(description),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_40()
|
||||
.flex_shrink_0()
|
||||
.justify_end()
|
||||
.items_center()
|
||||
.child(control),
|
||||
)
|
||||
}
|
||||
|
||||
/// A full-width settings block with title and subtitle in one header.
|
||||
pub fn setting_block(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
) -> impl IntoElement {
|
||||
let title = title.into();
|
||||
let description = description.into();
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.child(div().text_sm().font_semibold().child(title))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(description),
|
||||
),
|
||||
)
|
||||
.child(control)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App};
|
||||
use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// Status badge shown next to an issue or pull request.
|
||||
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"open",
|
||||
"Issue is open",
|
||||
cx.theme().secondary,
|
||||
cx.theme().secondary_foreground,
|
||||
),
|
||||
RepoStatus::Closed => (
|
||||
CustomIconName::GitIssueClosed,
|
||||
"closed",
|
||||
"Issue is closed",
|
||||
cx.theme().danger,
|
||||
cx.theme().danger_foreground,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
CustomIconName::GitIssueOngoing,
|
||||
"draft",
|
||||
"Issue is draft",
|
||||
cx.theme().accent,
|
||||
cx.theme().accent_foreground,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
CustomIconName::GitIssueDone,
|
||||
"applied",
|
||||
"Issue is completed",
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(label)
|
||||
.flex_shrink_0()
|
||||
.size_7()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(bg)
|
||||
.child(Icon::new(icon).small().text_color(fg))
|
||||
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
use gpui::{
|
||||
App, Div, InteractiveElement as _, MouseButton, Stateful, StatefulInteractiveElement as _,
|
||||
Window, WindowControlArea,
|
||||
};
|
||||
|
||||
/// State used to move the window when the title bar area is dragged.
|
||||
struct WindowDragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
/// Make an element behave like a window title bar.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
|
||||
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = true;
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||
if state.should_move {
|
||||
state.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
if cfg!(target_os = "macos") {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
window.zoom_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Window, div, px};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::tree::TreeEntry;
|
||||
use gpui_component::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
let item = entry.item();
|
||||
let is_folder = entry.is_folder();
|
||||
|
||||
let icon = if is_folder {
|
||||
if entry.is_expanded() {
|
||||
IconName::FolderOpen
|
||||
} else {
|
||||
IconName::FolderClosed
|
||||
}
|
||||
} else {
|
||||
IconName::File
|
||||
};
|
||||
|
||||
ListItem::new(ix)
|
||||
.pl(px(8.) + px(14.) * entry.depth() as f32)
|
||||
.selected(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.child(Icon::new(icon).small())
|
||||
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
if is_folder {
|
||||
return;
|
||||
}
|
||||
on_click(window, cx);
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
[package]
|
||||
name = "workspace"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
dock = { workspace = true }
|
||||
metadata = { path = "../metadata" }
|
||||
paths = { path = "../paths" }
|
||||
settings = { path = "../settings" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_git = { path = "../signed_git" }
|
||||
signed_state = { path = "../signed_state" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
@@ -1,13 +0,0 @@
|
||||
mod views;
|
||||
mod workspace;
|
||||
|
||||
use gpui::{App, AppContext, Entity, Window};
|
||||
use gpui_component::Root;
|
||||
pub use views::{RepoListView, SidebarPanel};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
/// Build the root view tree.
|
||||
pub fn root(window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let view = cx.new(|cx| Workspace::new(window, cx));
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
ScrollStrategy, SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::resizable::{resizable_panel, v_resizable};
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_git::{
|
||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||
};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use crate::views::tree::{build_tree_items, tree_items};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
pub struct DiffPane {
|
||||
diff: Option<CommitDiff>,
|
||||
tree_state: Entity<TreeState>,
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
rows: Vec<DiffRow>,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
}
|
||||
|
||||
impl DiffPane {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
diff: None,
|
||||
tree_state: cx.new(|cx| TreeState::new(cx)),
|
||||
selected_file: None,
|
||||
rows: Vec::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff(&self) -> Option<&CommitDiff> {
|
||||
self.diff.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| PathBuf::from(&file.path))
|
||||
.collect();
|
||||
paths.sort();
|
||||
let items = tree_items(build_tree_items(&paths), true);
|
||||
let first = diff
|
||||
.files
|
||||
.first()
|
||||
.map(|file| SharedString::from(file.path.as_str()));
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(items.clone(), cx);
|
||||
let item = find_item(&items, first.as_deref());
|
||||
state.set_selected_item(item, cx);
|
||||
});
|
||||
self.selected_file = first.clone();
|
||||
self.diff = Some(diff);
|
||||
if let Some(path) = first {
|
||||
self.set_diff_rows(path.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
self.diff = None;
|
||||
self.selected_file = None;
|
||||
self.rows = Vec::new();
|
||||
self.item_sizes = Rc::new(Vec::new());
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(Vec::new(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
|
||||
return;
|
||||
};
|
||||
self.rows = diff_rows(file);
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]);
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |_window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.select_file(&id, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.when(self.diff.is_some(), |this| {
|
||||
this.child(
|
||||
tree(&tree_state, move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
})
|
||||
.p_2(),
|
||||
)
|
||||
})
|
||||
.when(self.diff.is_none(), |this| {
|
||||
this.child(placeholder("No changes", cx))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("No changes", cx);
|
||||
};
|
||||
let Some(path) = self.selected_file.clone() else {
|
||||
return if diff.files.is_empty() {
|
||||
placeholder("No files changed", cx)
|
||||
} else {
|
||||
placeholder("Select a file", cx)
|
||||
};
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||
return placeholder("File not found", cx);
|
||||
};
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
DiffStatus::Added => "A",
|
||||
DiffStatus::Modified => "M",
|
||||
DiffStatus::Deleted => "D",
|
||||
DiffStatus::Renamed => "R",
|
||||
DiffStatus::Copied => "C",
|
||||
};
|
||||
let status_color = match file.status {
|
||||
DiffStatus::Added => cx.theme().success,
|
||||
DiffStatus::Modified => cx.theme().info,
|
||||
DiffStatus::Deleted => cx.theme().danger,
|
||||
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().muted_foreground,
|
||||
};
|
||||
let title = match &file.old_path {
|
||||
Some(old) => format!("{old} → {}", file.path),
|
||||
None => file.path.clone(),
|
||||
};
|
||||
|
||||
let body: AnyElement = if file.binary {
|
||||
placeholder("Diff not available", cx)
|
||||
} else if file.hunks.is_empty() {
|
||||
placeholder("No content changes", cx)
|
||||
} else {
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
v_virtual_list(
|
||||
view,
|
||||
"commit-diff-rows",
|
||||
sizes,
|
||||
move |this, range, _window, cx| {
|
||||
let Some(diff) = this.diff.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(path) = this.selected_file.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
range
|
||||
.map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx))
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(status_color)
|
||||
.child(status_label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(title),
|
||||
)
|
||||
.when(!file.binary, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{}", file.insertions)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("-{}", file.deletions)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("commit-diff-body").flex_1().min_h_0().child(body))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DiffPane {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.bg(cx.theme().background)
|
||||
.child(self.render_tree_column(cx))
|
||||
.child(self.render_detail_column(cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitDiffView {
|
||||
focus_handle: FocusHandle,
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
repo_name: SharedString,
|
||||
commit: FileCommit,
|
||||
/// The diff is being computed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
pane: Entity<DiffPane>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
impl CommitDiffView {
|
||||
pub fn new(
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
repo_name: SharedString,
|
||||
commit_id: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let pane = cx.new(DiffPane::new);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
// Defer until the window is ready, like the repository detail view.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
worktree,
|
||||
repo_name,
|
||||
commit: FileCommit {
|
||||
id: commit_id,
|
||||
summary: String::new(),
|
||||
description: None,
|
||||
author: String::new(),
|
||||
time: 0,
|
||||
},
|
||||
loading: true,
|
||||
error: None,
|
||||
pane,
|
||||
directory,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let worktree = self.worktree.clone();
|
||||
let id = self.commit.id.clone();
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let commit = cx
|
||||
.background_spawn({
|
||||
let worktree = worktree.clone();
|
||||
let id = id.clone();
|
||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
|
||||
})
|
||||
.await;
|
||||
let diff = cx
|
||||
.background_spawn({
|
||||
let worktree = worktree.clone();
|
||||
let id = id.clone();
|
||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.loading = false;
|
||||
if let Ok(Some(commit)) = commit {
|
||||
this.commit = commit;
|
||||
}
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
(
|
||||
diff.files.len(),
|
||||
diff.files.iter().map(|file| file.insertions).sum(),
|
||||
diff.files.iter().map(|file| file.deletions).sum(),
|
||||
)
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(h_flex().child(format!("{} committed", commit.author)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(&commit.id))
|
||||
.child(Clipboard::new("commit").value(&commit.id)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(commit.description.as_ref(), |this, description| {
|
||||
this.child(div().text_sm().child(SharedString::from(description)))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
Tag::primary()
|
||||
.small()
|
||||
.child(format!("{files} files changed")),
|
||||
)
|
||||
.when(insertions > 0, |this| {
|
||||
this.child(
|
||||
Tag::success()
|
||||
.outline()
|
||||
.small()
|
||||
.child(format!("+ {insertions}")),
|
||||
)
|
||||
})
|
||||
.when(deletions > 0, |this| {
|
||||
this.child(
|
||||
Tag::danger()
|
||||
.outline()
|
||||
.small()
|
||||
.child(format!("- {deletions}")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.overflow_y_scrollbar()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for CommitDiffView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"commit_diff"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for CommitDiffView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for CommitDiffView {}
|
||||
|
||||
impl Focusable for CommitDiffView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommitDiffView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let body: AnyElement = if self.loading {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else if let Some(error) = self.error.clone() {
|
||||
placeholder(&error, cx)
|
||||
} else {
|
||||
self.pane.clone().into_any_element()
|
||||
};
|
||||
|
||||
v_resizable("commit-diff")
|
||||
.child(
|
||||
resizable_panel()
|
||||
.size(px(180.))
|
||||
.size_range(px(120.)..px(420.))
|
||||
.flex_none()
|
||||
.bg(cx.theme().background)
|
||||
.child(self.render_header(cx)),
|
||||
)
|
||||
.child(resizable_panel().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
const GUTTER_WIDTH: f32 = 44.;
|
||||
const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
Line {
|
||||
hunk: usize,
|
||||
line: usize,
|
||||
},
|
||||
}
|
||||
|
||||
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(crate) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Context, SharedString, Subscription, div};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use signed_core::RepoAddr;
|
||||
use signed_state::{Backend, BackendEvent, PushStepStatus};
|
||||
|
||||
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
// The repository whose push checklist is shown; `None` outside a publish.
|
||||
push_addr: Option<RepoAddr>,
|
||||
pub push_steps: Vec<PushStepStatus>,
|
||||
_push_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
/// Marks an action as started, disabling the form and clearing the previous error.
|
||||
pub fn begin(&mut self) {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// Starts a publish for the repository at `addr` and listens for its push steps.
|
||||
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
self.begin();
|
||||
self.push_addr = Some(addr);
|
||||
self.push_steps.clear();
|
||||
|
||||
if self._push_subscription.is_none() {
|
||||
let backend = Backend::global(cx);
|
||||
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let BackendEvent::PushProgress { addr, status } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if this.push_addr.as_ref() != Some(addr) {
|
||||
return;
|
||||
}
|
||||
|
||||
match this
|
||||
.push_steps
|
||||
.iter_mut()
|
||||
.find(|step| step.step() == status.step())
|
||||
{
|
||||
Some(step) => *step = status.clone(),
|
||||
None => this.push_steps.push(status.clone()),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks an action as failed, enabling the form and showing `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
self.error = Some(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared error line under a dialog form, `None` when there is no error.
|
||||
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||
error.as_ref().map(|message| {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(message.clone())
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
/// The push checklist of a running publish, `None` before any step is reported.
|
||||
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
|
||||
if steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rows = steps
|
||||
.iter()
|
||||
.map(|status| {
|
||||
let label = SharedString::from(status.step().label());
|
||||
|
||||
match status {
|
||||
PushStepStatus::Running(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Done(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(Icon::new(IconName::CircleCheck).small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Failed(_, reason) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(Icon::new(IconName::Close).small())
|
||||
.child(SharedString::from(format!(
|
||||
"{}: {reason}",
|
||||
status.step().label()
|
||||
)))
|
||||
.into_any_element(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(v_flex().gap_1().children(rows).into_any_element())
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::Avatar;
|
||||
use utils::relative_time;
|
||||
|
||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comments_section(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = directory.read(cx).profile(&comment.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(comment.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||
ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div,
|
||||
list, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||
use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
|
||||
use signed_ui::{Avatar, CountBadge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::{RepoItem, open_repo_item, panel_avatar, tab_title};
|
||||
|
||||
const LIST_OVERDRAW: Pixels = px(400.);
|
||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||
|
||||
struct InboxSection {
|
||||
/// `None` for items without a repository.
|
||||
address: Option<RepoAddr>,
|
||||
unread: usize,
|
||||
/// Indices into the threads, newest activity first.
|
||||
entries: Vec<usize>,
|
||||
/// Timestamp of the newest entry, used to order the sections.
|
||||
latest: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxRow {
|
||||
Repo(usize),
|
||||
Entry(usize, usize),
|
||||
Empty,
|
||||
}
|
||||
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// One row per thread, merging notifications and own activity, newest first.
|
||||
threads: Arc<Vec<InboxItem>>,
|
||||
sections: Arc<Vec<InboxSection>>,
|
||||
rows: Arc<Vec<InboxRow>>,
|
||||
unread_count: usize,
|
||||
/// Copy of the global read state the current lists were derived with.
|
||||
state: InboxReadState,
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Profiles for activity authors.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InboxView {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
let repos = RepoListStore::global(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
|
||||
this.sync_state(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
this.handle_backend_event(event, cx);
|
||||
}));
|
||||
|
||||
// Rebuild when the user's own repositories load or change,
|
||||
// so a repository without any activity still gets an empty section.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
// Derive the sections once the panel exists.
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
|
||||
log::warn!("inbox dropped before bootstrap could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
threads: Arc::new(Vec::new()),
|
||||
sections: Arc::new(Vec::new()),
|
||||
rows: Arc::new(Vec::new()),
|
||||
unread_count: 0,
|
||||
state: InboxReadState::default(),
|
||||
state_loaded: false,
|
||||
refresh: RefreshGate::default(),
|
||||
list,
|
||||
tasks: vec![],
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let all: Vec<Event> = self
|
||||
.threads
|
||||
.iter()
|
||||
.flat_map(|item| item.events.iter().cloned())
|
||||
.collect();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
|
||||
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
|
||||
}
|
||||
|
||||
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
|
||||
let (loaded, state) = {
|
||||
let inbox = inbox.read(cx);
|
||||
(inbox.is_loaded(), inbox.state().clone())
|
||||
};
|
||||
|
||||
if !loaded {
|
||||
let was_present =
|
||||
self.state_loaded || !self.threads.is_empty() || !self.sections.is_empty();
|
||||
self.clear();
|
||||
if was_present {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.state_loaded {
|
||||
self.state_loaded = true;
|
||||
self.state = state;
|
||||
self.refresh_initial(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state != state {
|
||||
self.state = state;
|
||||
self.regroup(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
BackendEvent::RepoUpdates(_) => self.refresh(cx),
|
||||
BackendEvent::Synced => self.refresh(cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.state_loaded {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let (me, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(me) = me else {
|
||||
self.refresh.abort();
|
||||
return;
|
||||
};
|
||||
|
||||
let state = self.state.clone();
|
||||
|
||||
let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (threads, unread_count) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
log::warn!("inbox refresh failed: {error}");
|
||||
return this.update(cx, |this, _cx| this.refresh.abort());
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
if backend.read(cx).current_user() != Some(me) {
|
||||
this.refresh.abort();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.threads = Arc::new(threads);
|
||||
this.unread_count = unread_count;
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn regroup(&mut self, cx: &mut Context<Self>) {
|
||||
let mut items = (*self.threads).clone();
|
||||
|
||||
for item in items.iter_mut() {
|
||||
item.apply_state(&self.state);
|
||||
}
|
||||
|
||||
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
self.threads = Arc::new(items);
|
||||
self.rebuild(cx);
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let mut sections = self.group_sections();
|
||||
|
||||
if let Some(me) = backend.read(cx).current_user() {
|
||||
for announcement in repo_list.read(cx).announcements_of(&me) {
|
||||
let address = announcement.addr();
|
||||
let known = sections
|
||||
.iter()
|
||||
.any(|section| section.address.as_ref() == Some(&address));
|
||||
|
||||
if !known {
|
||||
sections.push(InboxSection {
|
||||
address: Some(address),
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
|
||||
let rows = self.flatten_rows(§ions);
|
||||
self.sections = Arc::new(sections);
|
||||
self.rows = Arc::new(rows);
|
||||
|
||||
let mut keys: Vec<PublicKey> = self
|
||||
.threads
|
||||
.iter()
|
||||
.flat_map(|item| item.events.iter().map(|event| event.pubkey))
|
||||
.collect();
|
||||
|
||||
keys.sort_by_key(PublicKey::to_hex);
|
||||
keys.dedup();
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn group_sections(&self) -> Vec<InboxSection> {
|
||||
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
|
||||
|
||||
for (ix, item) in self.threads.iter().enumerate() {
|
||||
if item.archived {
|
||||
continue;
|
||||
}
|
||||
|
||||
let address = item.address.clone();
|
||||
let section = by_repo
|
||||
.entry(address.clone())
|
||||
.or_insert_with(move || InboxSection {
|
||||
address,
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
|
||||
if item.is_unread() {
|
||||
section.unread += 1;
|
||||
}
|
||||
|
||||
section.latest = section.latest.max(item.latest_activity());
|
||||
section.entries.push(ix);
|
||||
}
|
||||
|
||||
let mut sections: Vec<InboxSection> = by_repo.into_values().collect();
|
||||
|
||||
for section in &mut sections {
|
||||
section.entries.sort_by(|a, b| {
|
||||
self.threads[*b]
|
||||
.latest_activity()
|
||||
.cmp(&self.threads[*a].latest_activity())
|
||||
});
|
||||
}
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
sections
|
||||
}
|
||||
|
||||
fn flatten_rows(&self, sections: &[InboxSection]) -> Vec<InboxRow> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (section_ix, section) in sections.iter().enumerate() {
|
||||
rows.push(InboxRow::Repo(section_ix));
|
||||
|
||||
if section.entries.is_empty() {
|
||||
rows.push(InboxRow::Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.extend(
|
||||
(0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)),
|
||||
);
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.threads = Arc::new(Vec::new());
|
||||
self.sections = Arc::new(Vec::new());
|
||||
self.rows = Arc::new(Vec::new());
|
||||
self.unread_count = 0;
|
||||
self.state = InboxReadState::default();
|
||||
self.state_loaded = false;
|
||||
// Drop any in-flight or pending run belonging to the previous user.
|
||||
self.refresh = RefreshGate::default();
|
||||
}
|
||||
|
||||
fn open(
|
||||
&self,
|
||||
root: EventId,
|
||||
kind: Option<Kind>,
|
||||
address: Option<RepoAddr>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(address) = address else {
|
||||
return;
|
||||
};
|
||||
|
||||
let item = match kind {
|
||||
Some(Kind::GitIssue) => RepoItem::Issue(root),
|
||||
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
|
||||
Some(Kind::GitPatch) => RepoItem::Patch,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
open_repo_item(&self.dock_area, &address, None, item, window, cx);
|
||||
}
|
||||
|
||||
fn render_entry(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||
let Some(row) = self.rows.get(ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
match *row {
|
||||
InboxRow::Empty => empty_section_row(cx),
|
||||
InboxRow::Repo(section_ix) => {
|
||||
let Some(section) = self.sections.get(section_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
repo_header(section, cx)
|
||||
}
|
||||
InboxRow::Entry(section_ix, entry_ix) => {
|
||||
let Some(section) = self.sections.get(section_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let Some(&thread_ix) = section.entries.get(entry_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let Some(item) = self.threads.get(thread_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let root = item.root;
|
||||
let kind = item.root_event.as_ref().map(|event| event.kind);
|
||||
let address = section.address.clone();
|
||||
let first = entry_ix == 0;
|
||||
let last = entry_ix + 1 == section.entries.len();
|
||||
let directory = self.directory.clone();
|
||||
|
||||
thread("inbox-row", ix, item, first, last, &directory, cx)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open(root, kind, address.clone(), window, cx)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let addr = addr?;
|
||||
repo_list
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == *addr)
|
||||
.map(|announcement| announcement.name().map(SharedString::from))
|
||||
}
|
||||
|
||||
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
let name =
|
||||
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
|
||||
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when(section.unread > 0, |this| {
|
||||
this.child(CountBadge::new(section.unread))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn empty_section_row(cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary.alpha(0.6))
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from("No activity yet."))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn thread(
|
||||
prefix: &'static str,
|
||||
ix: usize,
|
||||
item: &InboxItem,
|
||||
first: bool,
|
||||
last: bool,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let title = SharedString::from(item.title());
|
||||
let unread = item.is_unread();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let me = backend.read(cx).current_user();
|
||||
|
||||
let mut timeline = v_flex().gap_2().w_full();
|
||||
|
||||
for event in item.timeline(MAX_SUB_ACTIVITIES) {
|
||||
timeline = timeline.child(sub_activity(&event, me, directory, cx));
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.id((prefix, ix))
|
||||
.w_full()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().secondary.alpha(0.6))
|
||||
.when(first, |this| this.rounded_t(cx.theme().radius))
|
||||
.when(last, |this| this.rounded_b(cx.theme().radius))
|
||||
.when(!last, |this| {
|
||||
this.border_b_1().border_color(cx.theme().background)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.size_6()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Icon::new(IconName::Bell)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(title),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.when(unread, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_2()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().primary),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(timeline)
|
||||
}
|
||||
|
||||
fn sub_activity(
|
||||
event: &Event,
|
||||
me: Option<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = directory.read(cx).profile(&event.pubkey, cx);
|
||||
|
||||
let name = if Some(event.pubkey) == me {
|
||||
SharedString::from("You")
|
||||
} else {
|
||||
profile.name()
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(div().w_6().flex_shrink_0())
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
Avatar::new(event.pubkey)
|
||||
.name(name.clone())
|
||||
.picture(profile.picture())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(name)
|
||||
.child(SharedString::from(activity_phrase(event.kind)))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(relative_time(event.created_at))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn activity_phrase(kind: Kind) -> &'static str {
|
||||
match kind {
|
||||
Kind::GitIssue => "opened an issue",
|
||||
Kind::GitPullRequest => "opened a PR",
|
||||
Kind::GitPullRequestUpdate => "updated a PR",
|
||||
Kind::GitPatch => "created a patch",
|
||||
Kind::Comment => "commented",
|
||||
Kind::GitStatusOpen => "opened a status",
|
||||
Kind::GitStatusApplied => "applied a status",
|
||||
Kind::GitStatusClosed => "closed a status",
|
||||
Kind::GitStatusDraft => "drafted a status",
|
||||
_ => "did something",
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.py_8()
|
||||
.child(
|
||||
Icon::new(icon)
|
||||
.large()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(message)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl BasePanel for InboxView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"inbox"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for InboxView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
tab_title(panel_avatar("inbox"), SharedString::from("Inbox"))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for InboxView {}
|
||||
|
||||
impl Focusable for InboxView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InboxView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let rows = self.rows.clone();
|
||||
|
||||
if self.list.item_count() != rows.len() {
|
||||
self.list.reset(rows.len());
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("inbox"))
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_4()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Inbox")),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("mark-all")
|
||||
.icon(IconName::CircleCheck)
|
||||
.secondary()
|
||||
.tooltip("Mark all as read")
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.mark_all_read(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.px_4()
|
||||
.when_else(
|
||||
rows.is_empty(),
|
||||
|this| {
|
||||
this.child(empty_state(IconName::Inbox, "You're all caught up.", cx))
|
||||
},
|
||||
|this| {
|
||||
this.child(
|
||||
list(
|
||||
self.list.clone(),
|
||||
cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
|
||||
)
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.into_any_element(),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(div().h_6().w_full().flex_shrink_0()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription,
|
||||
Window, div, relative,
|
||||
};
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use signed_core::GitEvent;
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{Avatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
pub struct IssueDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
comment_input: Entity<TextareaState>,
|
||||
/// Profiles for the author, commenters and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssueDetailView {
|
||||
pub fn new(
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.track_participants(cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
directory,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the issue author, everyone who commented and the repository owner.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == self.issue_id)
|
||||
.map(|issue| issue.pubkey)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
keys.extend(
|
||||
store
|
||||
.comments_of(&self.issue_id)
|
||||
.map(|comment| comment.pubkey),
|
||||
);
|
||||
|
||||
if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for IssueDetailView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"issue_detail"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for IssueDetailView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let short_id = self
|
||||
.store
|
||||
.read(cx)
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == self.issue_id)
|
||||
.map(|issue| {
|
||||
let hex = issue.id.to_hex();
|
||||
SharedString::from(&hex[..8])
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
|
||||
tab_title(avatar, short_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for IssueDetailView {}
|
||||
|
||||
impl Focusable for IssueDetailView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for IssueDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
|
||||
// The store has not applied its first pass yet, the issue may still arrive.
|
||||
return if store.loaded {
|
||||
placeholder("Issue not found", cx)
|
||||
} else {
|
||||
placeholder("Loading issue...", cx)
|
||||
};
|
||||
};
|
||||
|
||||
let (title, author, picture, status, age, issue_id, content, pubkey) = {
|
||||
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
};
|
||||
|
||||
(
|
||||
issue.activity_subject(),
|
||||
profile.name(),
|
||||
profile.picture(),
|
||||
store.status_of(issue),
|
||||
relative_time(issue.created_at),
|
||||
issue.id,
|
||||
content,
|
||||
issue.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.image_cache(gpui::retain_all("issue-detail"))
|
||||
.id("issue-detail")
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.gap_6()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.overflow_y_scrollbar()
|
||||
.child(
|
||||
h_flex()
|
||||
.min_h_16()
|
||||
.gap_2()
|
||||
.child(status_badge(status, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(title),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.px_4()
|
||||
.gap_8()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from("opened")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content)),
|
||||
)
|
||||
.child(comments_section(&self.store, issue_id, &self.directory, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
&self.comment_input,
|
||||
"comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
false,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use signed_core::{GitEvent, RepoStatus};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
pub(super) mod detail;
|
||||
|
||||
use self::detail::IssueDetailView;
|
||||
use super::status_list::{StatusCounts, filter_by_status};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum IssueFilter {
|
||||
All,
|
||||
Open,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl IssueFilter {
|
||||
fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Open => status == RepoStatus::Open,
|
||||
Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IssuesView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
filter: IssueFilter,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
visible_issues: Vec<usize>,
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: IssueFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Profiles for issue authors and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssuesView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
repo_name,
|
||||
filter: IssueFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
visible_issues: Vec::new(),
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_issues, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
filter_by_status(
|
||||
&store.issues,
|
||||
|issue| store.status_of(issue),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store.issues.iter().map(|issue| issue.pubkey).collect();
|
||||
|
||||
if let Some(owner) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.owner)
|
||||
{
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
let visible_issues_changed = self.visible_issues != visible_issues;
|
||||
let counts_changed = self.counts != counts;
|
||||
|
||||
if !filter_changed && !visible_issues_changed && !counts_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.item_sizes = Rc::new(vec![
|
||||
size(px(0.), px(ISSUE_ROW_HEIGHT));
|
||||
visible_issues.len()
|
||||
]);
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.visible_issues = visible_issues;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Open the detail panel of `issue_id` in the dock area.
|
||||
fn open_issue_detail(
|
||||
&mut self,
|
||||
issue_id: EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let issue = &self.store.read(cx).issues[issue_ix];
|
||||
let title = issue.activity_subject();
|
||||
let id_hex = issue.id.to_hex();
|
||||
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(issue.created_at);
|
||||
let status = self.store.read(cx).status_of(issue);
|
||||
let issue_id = issue.id;
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.items_start()
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.open_issue_detail(issue_id, window, cx);
|
||||
}))
|
||||
.child(status_badge(status, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.line_clamp(1)
|
||||
.text_sm()
|
||||
.child(title),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(issue.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(&id_hex[..8])),
|
||||
)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let counts = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().muted.opacity(0.5))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_12()
|
||||
.gap_2()
|
||||
.child(
|
||||
SegmentButton::new("all", "All")
|
||||
.icon(Icon::new(CustomIconName::GitIssueDone))
|
||||
.count(counts.total)
|
||||
.selected(self.filter == IssueFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::All;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SegmentButton::new("open", "Open")
|
||||
.icon(Icon::new(CustomIconName::GitIssueOpen))
|
||||
.count(counts.open)
|
||||
.selected(self.filter == IssueFilter::Open)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Open;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SegmentButton::new("closed", "Closed")
|
||||
.icon(Icon::new(CustomIconName::GitIssueClosed))
|
||||
.count(counts.closed)
|
||||
.selected(self.filter == IssueFilter::Closed)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Closed;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
SegmentButton::new("new", "New issue")
|
||||
.icon(Icon::new(CustomIconName::CirclePlus))
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
open_new_issue_dialog(this.store.clone(), window, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
|
||||
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let subject = subject.clone();
|
||||
let content = content.clone();
|
||||
let store = store.clone();
|
||||
|
||||
dialog
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.content(move |body, _window, _cx| {
|
||||
body.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("New issue"))
|
||||
.child(
|
||||
DialogDescription::new()
|
||||
.child("Report a bug, ask a question, or propose a change."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Title")
|
||||
.required(true)
|
||||
.child(Input::new(&subject)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Content")
|
||||
.child(Textarea::new(&content).h(px(160.))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("submit")
|
||||
.primary()
|
||||
.label("Create issue")
|
||||
.tooltip("Create issue")
|
||||
.on_click({
|
||||
let subject = subject.clone();
|
||||
let content = content.clone();
|
||||
let store = store.clone();
|
||||
|
||||
move |_event, window, cx| {
|
||||
let subject = subject.read(cx).value().to_string();
|
||||
let content = content.read(cx).value().to_string();
|
||||
let subject = (!subject.is_empty()).then_some(subject);
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.open_issue(subject, content, cx);
|
||||
});
|
||||
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
impl BasePanel for IssuesView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"issues"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for IssuesView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for IssuesView {}
|
||||
|
||||
impl Focusable for IssuesView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
let count = self.visible_issues.len();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(gpui::retain_all("issues"))
|
||||
.child(self.render_header(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.when(count > 0, |this| {
|
||||
this.child(
|
||||
v_virtual_list(
|
||||
cx.entity().clone(),
|
||||
"issues",
|
||||
sizes,
|
||||
move |this, range, _window, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
let issue = this.visible_issues[ix];
|
||||
this.render_row(ix, issue, cx)
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&scroll_handle)),
|
||||
)
|
||||
})
|
||||
.when(count == 0, |this| {
|
||||
let message = match filter {
|
||||
IssueFilter::All => "No issues",
|
||||
IssueFilter::Open => "No open issues",
|
||||
IssueFilter::Closed => "No closed issues",
|
||||
};
|
||||
this.child(placeholder(message, cx))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
mod commit_diff;
|
||||
mod dialog_state;
|
||||
pub(crate) mod discussion;
|
||||
mod inbox;
|
||||
mod issues;
|
||||
mod placeholder_panel;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
mod send_patch;
|
||||
pub(crate) mod sidebar;
|
||||
mod status_list;
|
||||
pub(crate) mod tree;
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, div};
|
||||
use gpui_component::{Sizable as _, h_flex};
|
||||
pub use inbox::InboxView;
|
||||
use metadata::ProfileDirectory;
|
||||
pub(crate) use placeholder_panel::PlaceholderPanel;
|
||||
pub use repo::RepoDetailView;
|
||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::Avatar;
|
||||
|
||||
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 Display) -> AnyElement {
|
||||
Avatar::new(seed).xsmall().into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn repo_tab_avatar(
|
||||
store: &RepoStore,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
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| directory.read(cx).profile(&announcement.owner, cx));
|
||||
|
||||
let (name, picture) = owner
|
||||
.map(|profile| (Some(profile.name()), profile.picture()))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Avatar::new(seed)
|
||||
.name(name)
|
||||
.picture(picture)
|
||||
.xsmall()
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::{
|
||||
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, Render, SharedString, Window,
|
||||
};
|
||||
use signed_ui::placeholder;
|
||||
|
||||
/// A center panel reserving the place of a view that is not built yet.
|
||||
pub struct PlaceholderPanel {
|
||||
name: &'static str,
|
||||
title: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl PlaceholderPanel {
|
||||
pub(crate) fn new(name: &'static str, title: &'static str, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
title: SharedString::from(title),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for PlaceholderPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for PlaceholderPanel {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.title.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for PlaceholderPanel {}
|
||||
|
||||
impl Focusable for PlaceholderPanel {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PlaceholderPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
placeholder("Nothing here yet.", cx)
|
||||
}
|
||||
}
|
||||
@@ -1,861 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tab::{Tab, TabBar};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Url};
|
||||
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||
use signed_git::{FileCommit, PatchParser};
|
||||
use signed_state::{Backend, Mirrors, RepoStore};
|
||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
const ROW_HEIGHT: f32 = 37.;
|
||||
|
||||
/// Shown once the store's first pass is applied and the root PR is still absent.
|
||||
const NOT_FOUND: &str = "Pull request not found";
|
||||
|
||||
/// A store refresh re-binds the panel, and reloads only when these change.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct PrBinding {
|
||||
description: String,
|
||||
patch: String,
|
||||
tip: Option<String>,
|
||||
base: Option<String>,
|
||||
clone_urls: Vec<Url>,
|
||||
addr: RepoAddr,
|
||||
has_patch_link: bool,
|
||||
}
|
||||
|
||||
/// Detail panel of a single pull request.
|
||||
pub struct PullRequestDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
/// Event id of the root PR event, kind 1618. Updates are revisions.
|
||||
pr_id: EventId,
|
||||
comment_input: Entity<TextareaState>,
|
||||
repo_name: SharedString,
|
||||
/// Local clone the PR's git changes come from.
|
||||
worktree: Option<PathBuf>,
|
||||
description: SharedString,
|
||||
/// Tip commit of the PR, from the latest update's `c` tag or the root.
|
||||
current_commit: Option<SharedString>,
|
||||
/// Commits of the patch series, in patch order, oldest first.
|
||||
commits: Vec<FileCommit>,
|
||||
/// The patch is being parsed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Root PR inputs the in-flight diff load was started for.
|
||||
bound: Option<PrBinding>,
|
||||
/// Generation of the in-flight diff load. Stale results are discarded.
|
||||
load_generation: u64,
|
||||
/// 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
active_tab: usize,
|
||||
pane: Entity<DiffPane>,
|
||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
commit_scroll_handle: VirtualListScrollHandle,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
/// Profiles for the author, commenters and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestDetailView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
pr_id: EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
let pane = cx.new(DiffPane::new);
|
||||
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx));
|
||||
|
||||
// Defer loading until the window is ready, like the commit diff view.
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.sync(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
pr_id,
|
||||
comment_input,
|
||||
repo_name,
|
||||
worktree: None,
|
||||
description: SharedString::default(),
|
||||
current_commit: None,
|
||||
commits: Vec::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
bound: None,
|
||||
load_generation: 0,
|
||||
active_tab: 0,
|
||||
pane,
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
tasks: Vec::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the PR author, everyone who commented and the repository owner.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| vec![root.pubkey])
|
||||
.unwrap_or_default();
|
||||
|
||||
keys.extend(store.comments_of(&self.pr_id).map(|comment| comment.pubkey));
|
||||
|
||||
if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Snapshot the root PR from the store and reload the diff when it changed.
|
||||
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||
self.track_participants(cx);
|
||||
|
||||
let loaded = self.store.read(cx).loaded;
|
||||
|
||||
let binding = {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
store.addr().and_then(|addr| {
|
||||
store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| {
|
||||
let update = PullRequest::latest_update(store.pull_requests.iter(), root);
|
||||
|
||||
let tip = update
|
||||
.and_then(GitEvent::current_commit)
|
||||
.or_else(|| root.current_commit());
|
||||
|
||||
let base = update
|
||||
.and_then(GitEvent::merge_base)
|
||||
.or_else(|| root.merge_base());
|
||||
|
||||
let clone_urls = root
|
||||
.clone_urls()
|
||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
PrBinding {
|
||||
description: root.content.clone(),
|
||||
patch: PullRequest::new(root).patch(store.patches.iter()),
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr: addr.clone(),
|
||||
has_patch_link: root.tags.event_ids().next().is_some(),
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let Some(binding) = binding else {
|
||||
self.sync_missing(loaded, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
if self.bound.as_ref() == Some(&binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.bound = Some(binding.clone());
|
||||
self.load_diff(binding, cx);
|
||||
}
|
||||
|
||||
/// The store does not hold the root PR yet, or at all.
|
||||
///
|
||||
/// Loading until the first pass is applied, not found afterwards.
|
||||
fn sync_missing(&mut self, loaded: bool, cx: &mut Context<Self>) {
|
||||
self.bound = None;
|
||||
|
||||
if !loaded {
|
||||
if !self.loading || self.error.is_some() {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.error.as_deref() != Some(NOT_FOUND) {
|
||||
self.loading = false;
|
||||
self.error = Some(NOT_FOUND.into());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bound PR's changed files and commits.
|
||||
///
|
||||
/// Nostr-backed pull requests parse the patch series, git-backed ones fetch
|
||||
/// the clone and diff the `merge-base..tip` range.
|
||||
fn load_diff(&mut self, binding: PrBinding, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.description = binding.description.clone().into();
|
||||
self.current_commit = binding.tip.clone().map(SharedString::from);
|
||||
cx.notify();
|
||||
|
||||
self.load_generation = self.load_generation.wrapping_add(1);
|
||||
let generation = self.load_generation;
|
||||
|
||||
let PrBinding {
|
||||
patch,
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr,
|
||||
has_patch_link,
|
||||
..
|
||||
} = binding;
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { PatchParser::patch_diffs(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { PatchParser::patch_commits(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||
// Fetch the clone and diff the `merge-base..tip` range.
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = base.clone();
|
||||
let tip = tip.clone();
|
||||
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = Mirrors::ensure(&addr, &clone_urls)?;
|
||||
let gix_repo = repo.inner();
|
||||
|
||||
let workdir = gix_repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
|
||||
let tip =
|
||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||
None => {
|
||||
let head = gix_repo
|
||||
.head_id()
|
||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||
let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?;
|
||||
gix_repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let diff = repo.range_diff(&base, &tip)?;
|
||||
let commits = repo.commit_range(&base, &tip)?;
|
||||
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
||||
let (diff, commits, worktree) = match git {
|
||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||
None => (nostr_diff, nostr_commits, None),
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
// A newer binding superseded this load.
|
||||
if this.load_generation != generation {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||
this.commits = commits;
|
||||
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||
fn open_commit_diff(
|
||||
&mut self,
|
||||
worktree: PathBuf,
|
||||
commit_id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| {
|
||||
CommitDiffView::new(
|
||||
self.store.clone(),
|
||||
worktree,
|
||||
self.repo_name.clone(),
|
||||
commit_id.into(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Bottom, None, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let active = self.active_tab;
|
||||
let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len());
|
||||
let commits_count = if self.commits.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.commits.len())
|
||||
};
|
||||
|
||||
TabBar::new("pr-tabs")
|
||||
.underline()
|
||||
.small()
|
||||
.px_4()
|
||||
.w_full()
|
||||
.selected_index(active)
|
||||
.on_click(cx.listener(|this, index, _window, cx| {
|
||||
this.active_tab = *index;
|
||||
cx.notify();
|
||||
}))
|
||||
.child(Tab::new().label("Discussion"))
|
||||
.child(
|
||||
Tab::new()
|
||||
.label("Files")
|
||||
.when_some(files_count, |this, count| {
|
||||
this.suffix(CountBadge::new(count))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Tab::new()
|
||||
.label("Commits")
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.suffix(CountBadge::new(count))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
let (author, picture, age, root_id, pubkey) = {
|
||||
let store = self.store.read(cx);
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
return placeholder("Pull request not found", cx);
|
||||
};
|
||||
let profile = self.directory.read(cx).profile(&root.pubkey, cx);
|
||||
(
|
||||
profile.name(),
|
||||
profile.picture(),
|
||||
relative_time(root.created_at),
|
||||
root.id,
|
||||
root.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.p_4()
|
||||
.gap_6()
|
||||
.overflow_y_scrollbar()
|
||||
.child(
|
||||
v_flex()
|
||||
.px_4()
|
||||
.gap_8()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(SharedString::from("commented"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.when(!self.description.is_empty(), |this| {
|
||||
this.child(div().text_sm().child(self.description.clone()))
|
||||
}),
|
||||
)
|
||||
.child(comments_section(&self.store, root_id, &self.directory, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
&self.comment_input,
|
||||
"pr-comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
true,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.child(self.pane.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
if self.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(
|
||||
cx.entity().clone(),
|
||||
"pr-commits",
|
||||
self.commit_item_sizes.clone(),
|
||||
move |this, range, _window, cx| {
|
||||
range
|
||||
.map(|ix| this.render_commit_row(ix, &this.commits[ix], cx))
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(&self.commit_scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.commit_scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_commit_row(
|
||||
&self,
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let meta = commit_meta(commit);
|
||||
let id = commit.id.clone();
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(ROW_HEIGHT))
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.text_sm()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
)
|
||||
.when(!meta.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(meta)),
|
||||
)
|
||||
})
|
||||
// Commits parsed from the nostr patch set may not exist in any local clone.
|
||||
// Only git-backed PRs open a diff viewer.
|
||||
.when_some(self.worktree.clone(), |this, worktree| {
|
||||
this.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.open_commit_diff(worktree.clone(), &id, window, cx);
|
||||
}))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let current_commit = self.current_commit.clone();
|
||||
let (title, status, branch, author) = {
|
||||
let store = self.store.read(cx);
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
(
|
||||
root.activity_subject(),
|
||||
store.status_of(root),
|
||||
root.branch_name(),
|
||||
root.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
// Only the PR author may publish revisions, NIP-34 kind 1619.
|
||||
let backend = Backend::global(cx);
|
||||
let can_update = backend.read(cx).current_user() == Some(author);
|
||||
|
||||
v_flex()
|
||||
.px_4()
|
||||
.mb_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_h_16()
|
||||
.gap_2()
|
||||
.child(status_badge(status, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(title),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.when_some(branch, |this, branch| {
|
||||
this.child(
|
||||
Button::new("branch")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(CustomIconName::GitBranch)
|
||||
.label(branch),
|
||||
)
|
||||
})
|
||||
.when(can_update, |this| {
|
||||
this.child(
|
||||
Button::new("update-pr")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(CustomIconName::GitPullRequest)
|
||||
.label("Update")
|
||||
.tooltip("Publish a new revision of this pull request")
|
||||
.on_click(cx.listener({
|
||||
let store = self.store.clone();
|
||||
let pr_id = self.pr_id;
|
||||
move |_this, _event, window, cx| {
|
||||
let root = store
|
||||
.read(cx)
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| {
|
||||
pr.id == pr_id && pr.kind == Kind::GitPullRequest
|
||||
})
|
||||
.cloned();
|
||||
if let Some(root) = root {
|
||||
open_update_pull_request_dialog(
|
||||
store.clone(),
|
||||
root,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when_some(current_commit, |this, id| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(id.clone())
|
||||
.child(Clipboard::new("pr-commit").value(&id)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn open_update_pull_request_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
root: Event,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let patch = cx.new(|cx| {
|
||||
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
|
||||
});
|
||||
// Both the dialog body and submit button capture the root event.
|
||||
// Share it instead of cloning into each closure.
|
||||
let root = Rc::new(root);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let store = store.clone();
|
||||
let patch = patch.clone();
|
||||
let root = root.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |body, _window, _cx| {
|
||||
body.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Update pull request"))
|
||||
.child(DialogDescription::new().child(
|
||||
"Publish a new revision with the output of `git format-patch`.",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
v_form().child(
|
||||
field()
|
||||
.label("Patch")
|
||||
.child(Textarea::new(&patch).h(px(160.))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("submit")
|
||||
.primary()
|
||||
.label("Update pull request")
|
||||
.tooltip("Update pull request")
|
||||
.on_click({
|
||||
let store = store.clone();
|
||||
let patch = patch.clone();
|
||||
let root = root.clone();
|
||||
move |_event, window, cx| {
|
||||
let patch = patch.read(cx).value().to_string();
|
||||
store.update(cx, |store, cx| {
|
||||
store.update_pull_request(&root, patch, cx);
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// One-line commit metadata for the commits list.
|
||||
///
|
||||
/// Author and relative time, whichever is available.
|
||||
fn commit_meta(commit: &FileCommit) -> String {
|
||||
let author = commit.author.trim();
|
||||
let time = commit.time > 0;
|
||||
match (author.is_empty(), time) {
|
||||
(false, true) => format!("{author} · {}", relative_time_secs(commit.time)),
|
||||
(false, false) => author.to_string(),
|
||||
(true, true) => relative_time_secs(commit.time),
|
||||
(true, false) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for PullRequestDetailView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"pull-request-detail"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for PullRequestDetailView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let hex = self.pr_id.to_hex();
|
||||
let id = SharedString::from(&hex[..8]);
|
||||
let label = if self.repo_name.is_empty() {
|
||||
id
|
||||
} else {
|
||||
SharedString::from(format!("{}/{}", self.repo_name, id))
|
||||
};
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for PullRequestDetailView {}
|
||||
|
||||
impl Focusable for PullRequestDetailView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PullRequestDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("pull-request-detail"))
|
||||
.id("pull-request-detail")
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.child(self.render_header(cx))
|
||||
.child(self.render_tabs(cx))
|
||||
.map(|this| match self.active_tab {
|
||||
0 => this.child(self.render_discussion(cx)),
|
||||
1 => this.child(self.render_files_tab(cx)),
|
||||
_ => this.child(self.render_commits_tab(cx)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, Kind, PublicKey};
|
||||
use signed_core::{GitEvent, RepoStatus};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
pub(super) mod detail;
|
||||
pub(super) mod new;
|
||||
|
||||
use self::detail::PullRequestDetailView;
|
||||
use self::new::open_new_pull_panel;
|
||||
use super::send_patch::open_send_patch_panel;
|
||||
use super::status_list::{StatusCounts, filter_by_status};
|
||||
use crate::views::repo::RepoAction;
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
const ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PullRequestFilter {
|
||||
All,
|
||||
Open,
|
||||
Closed,
|
||||
Draft,
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl PullRequestFilter {
|
||||
fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Open => status == RepoStatus::Open,
|
||||
Self::Closed => status == RepoStatus::Closed,
|
||||
Self::Draft => status == RepoStatus::Draft,
|
||||
Self::Merged => status == RepoStatus::Applied,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PullRequestsView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
filter: PullRequestFilter,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
||||
visible_prs: Vec<usize>,
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: PullRequestFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Profiles for PR authors and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestsView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
repo_name,
|
||||
filter: PullRequestFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
visible_prs: Vec::new(),
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: PullRequestFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_prs, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
let roots = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest);
|
||||
filter_by_status(
|
||||
roots,
|
||||
|pr| store.status_of(pr),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest)
|
||||
.map(|pr| pr.pubkey)
|
||||
.collect();
|
||||
|
||||
if let Some(owner) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.owner)
|
||||
{
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
|
||||
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
||||
return;
|
||||
}
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); visible_prs.len()]);
|
||||
self.visible_prs = visible_prs;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_pull_request_detail(
|
||||
&mut self,
|
||||
pr_id: EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| {
|
||||
PullRequestDetailView::new(
|
||||
self.dock_area.clone(),
|
||||
self.store.clone(),
|
||||
pr_id,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||
let pr_id = pr.id;
|
||||
let title = pr.activity_subject();
|
||||
let id_hex = pr.id.to_hex();
|
||||
|
||||
let age = relative_time(pr.created_at);
|
||||
let status = self.store.read(cx).status_of(pr);
|
||||
|
||||
let profile = self.directory.read(cx).profile(&pr.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.items_start()
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.open_pull_request_detail(pr_id, window, cx);
|
||||
}))
|
||||
.child(status_badge(status, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.line_clamp(1)
|
||||
.text_sm()
|
||||
.child(title),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pr.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(&id_hex[..8])),
|
||||
)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let counts = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().muted.opacity(0.5))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_12()
|
||||
.gap_1()
|
||||
.child(
|
||||
SegmentButton::new("all", "All")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||
.count(counts.total)
|
||||
.selected(self.filter == PullRequestFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::All;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SegmentButton::new("open", "Open")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||
.count(counts.open)
|
||||
.selected(self.filter == PullRequestFilter::Open)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Open;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SegmentButton::new("closed", "Closed")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
|
||||
.count(counts.closed)
|
||||
.selected(self.filter == PullRequestFilter::Closed)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Closed;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SegmentButton::new("draft", "Draft")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
|
||||
.count(counts.draft)
|
||||
.selected(self.filter == PullRequestFilter::Draft)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Draft;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
SegmentButton::new("merged", "Merged")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
|
||||
.count(counts.applied)
|
||||
.selected(self.filter == PullRequestFilter::Merged)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Merged;
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
h_flex().items_center().child(
|
||||
DropdownButton::new("new-pr-actions")
|
||||
.action(
|
||||
BaseButton::new("new-pr")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().primary)
|
||||
.hover(|this| this.bg(cx.theme().primary_hover))
|
||||
.text_sm()
|
||||
.text_color(cx.theme().primary_foreground)
|
||||
.child(Icon::new(IconName::Plus))
|
||||
.child("New"),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
open_new_pull_panel(
|
||||
this.dock_area.clone(),
|
||||
this.store.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
)
|
||||
.dropdown_menu(|menu, _, _| {
|
||||
menu.menu_element(Box::new(RepoAction::SendPatch), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::File))
|
||||
.child("Send Patch")
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for PullRequestsView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"pull-requests"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for PullRequestsView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for PullRequestsView {}
|
||||
|
||||
impl Focusable for PullRequestsView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
let count = self.visible_prs.len();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let view = cx.entity().clone();
|
||||
|
||||
let (last_error, last_warning) = {
|
||||
let store = self.store.read(cx);
|
||||
(store.last_error.clone(), store.last_warning.clone())
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(gpui::retain_all("pull-requests"))
|
||||
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
|
||||
if action == &RepoAction::SendPatch {
|
||||
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
|
||||
}
|
||||
}))
|
||||
.child(self.render_header(cx))
|
||||
.when_some(last_warning, |this, warning| {
|
||||
this.child(Alert::warning("pr-warning", warning).banner().on_close({
|
||||
let store = self.store.clone();
|
||||
move |_event, _window, cx| {
|
||||
store.update(cx, |store, _| store.last_warning = None);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.when_some(last_error, |this, error| {
|
||||
this.child(Alert::error("pr-error", error).banner().on_close({
|
||||
let store = self.store.clone();
|
||||
move |_event, _window, cx| {
|
||||
store.update(cx, |store, _| store.last_error = None);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.when(count > 0, |this| {
|
||||
this.child(
|
||||
v_virtual_list(view, "prl", sizes, move |this, range, _window, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
let pr_ix = this.visible_prs[ix];
|
||||
this.render_row(ix, pr_ix, cx)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&scroll_handle)),
|
||||
)
|
||||
})
|
||||
.when(count == 0, |this| {
|
||||
let message = match filter {
|
||||
PullRequestFilter::All => "No pull requests",
|
||||
PullRequestFilter::Open => "No open pull requests",
|
||||
PullRequestFilter::Closed => "No closed pull requests",
|
||||
PullRequestFilter::Draft => "No draft pull requests",
|
||||
PullRequestFilter::Merged => "No merged pull requests",
|
||||
};
|
||||
this.child(placeholder(message, cx))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,236 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, Window, div, px};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_ui::Avatar;
|
||||
use utils::middle_truncate;
|
||||
|
||||
pub(super) fn open_about_dialog(
|
||||
announcement: Announcement,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
dialog
|
||||
.w(px(500.))
|
||||
.h(px(600.))
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.title("About")
|
||||
.child(announcement_rows(&announcement, &directory, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn announcement_rows(
|
||||
announcement: &Announcement,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
rows.push(row(
|
||||
"Name",
|
||||
text(
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
|
||||
rows.push(row(
|
||||
"Description",
|
||||
text(
|
||||
announcement
|
||||
.description
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
|
||||
if !announcement.web.is_empty() {
|
||||
rows.push(row(
|
||||
"Web",
|
||||
list(
|
||||
"about-web",
|
||||
announcement.web.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(euc) = &announcement.euc {
|
||||
rows.push(row(
|
||||
"Earliest Commit",
|
||||
copy_value("about-euc", euc.clone(), cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(upstream) = &announcement.upstream {
|
||||
rows.push(row("Upstream", text(upstream.display()), cx));
|
||||
}
|
||||
|
||||
if !announcement.hashtags.is_empty() {
|
||||
rows.push(row(
|
||||
"Hashtags",
|
||||
text(SharedString::from(announcement.hashtags.join(", "))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.clone.is_empty() {
|
||||
rows.push(row(
|
||||
"Clone URLs",
|
||||
list(
|
||||
"about-clone",
|
||||
announcement.clone.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.relays.is_empty() {
|
||||
rows.push(row(
|
||||
"Grasp Relays",
|
||||
list(
|
||||
"about-relays",
|
||||
announcement.relays.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.maintainers.is_empty() {
|
||||
rows.push(row(
|
||||
"Maintainers",
|
||||
maintainers(&announcement.maintainers, directory, cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
||||
}
|
||||
|
||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(value)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn text<T>(value: T) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let value = value.into();
|
||||
|
||||
div()
|
||||
.text_sm()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.child(value)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.min_w_0()
|
||||
.child(SharedString::from(value.clone())),
|
||||
)
|
||||
.child(Clipboard::new(id).tooltip("Copy").value(value))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn maintainers(
|
||||
maintainers: &[PublicKey],
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(maintainers.iter().map(|pubkey| {
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(name),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(items.into_iter().enumerate().map(|(ix, item)| {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(SharedString::from(middle_truncate(&item, 28, 16))),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(format!("{id}-{ix}"))
|
||||
.tooltip("Copy")
|
||||
.value(item),
|
||||
)
|
||||
.into_any_element()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, WeakEntity, Window};
|
||||
use gpui_base::dock::PanelView;
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::issues::detail::IssueDetailView;
|
||||
use crate::views::pull_requests::detail::PullRequestDetailView;
|
||||
|
||||
/// Open repository as a panel in the dock's center.
|
||||
pub(crate) fn open_repo_panel(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<RepoDetailView> {
|
||||
let detail = cx
|
||||
.new(|cx| RepoDetailView::new(dock_area.clone(), addr.clone(), hint.cloned(), window, cx));
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(detail.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
detail
|
||||
}
|
||||
|
||||
/// The nostr store of `addr`'s repository, without opening a repository panel.
|
||||
fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity<RepoStore> {
|
||||
cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx))
|
||||
}
|
||||
|
||||
/// An item of a repository to open from outside its detail panel.
|
||||
pub(crate) enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// The repository store is built here.
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel: Arc<dyn PanelView> =
|
||||
match item {
|
||||
RepoItem::Issue(issue_id) => {
|
||||
let store = repo_store(addr, hint, cx);
|
||||
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
||||
}
|
||||
RepoItem::PullRequest(pr_id) => {
|
||||
let store = repo_store(addr, hint, cx);
|
||||
panel_handle(cx.new(|cx| {
|
||||
PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)
|
||||
}))
|
||||
}
|
||||
RepoItem::Patch => return,
|
||||
};
|
||||
|
||||
let Some(dock_area) = dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel, window, cx);
|
||||
});
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Banners {
|
||||
dismissed: HashSet<(PathBuf, String)>,
|
||||
sync_dismissed: HashSet<PathBuf>,
|
||||
ready_requested: bool,
|
||||
ready_head: Option<String>,
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
}
|
||||
|
||||
impl Banners {
|
||||
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
|
||||
self.dismissed
|
||||
.contains(&(status.path.clone(), status.branch.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
|
||||
self.dismissed
|
||||
.insert((status.path.clone(), status.branch.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
|
||||
self.sync_dismissed.contains(&checkout.path)
|
||||
}
|
||||
|
||||
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
|
||||
self.sync_dismissed.insert(checkout.path.clone());
|
||||
}
|
||||
|
||||
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
|
||||
(self.ready_requested, &self.ready_head)
|
||||
}
|
||||
|
||||
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
|
||||
self.ready_requested = true;
|
||||
self.ready_head = head;
|
||||
}
|
||||
|
||||
pub(super) fn set_statuses(
|
||||
&mut self,
|
||||
ready: Vec<CheckoutStatus>,
|
||||
push: Vec<CheckoutStatus>,
|
||||
) -> bool {
|
||||
let changed = ready != self.ready_statuses || push != self.push_statuses;
|
||||
self.ready_statuses = ready;
|
||||
self.push_statuses = push;
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Editor, EditorState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use crate::views::tree::{TreeItemSeed, tree_items};
|
||||
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
const MAX_PREVIEWED_FILES: usize = 32;
|
||||
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
enum FileContent {
|
||||
Text(String),
|
||||
Binary,
|
||||
TooLarge,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct MarkdownView {
|
||||
/// `None` means the repository README.
|
||||
path: Option<SharedString>,
|
||||
state: Entity<TextViewState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
path: SharedString,
|
||||
state: Entity<EditorState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
pub(super) struct RepoFilesView {
|
||||
tree_state: Entity<TreeState>,
|
||||
worktree: Option<PathBuf>,
|
||||
worktree_paths: Vec<String>,
|
||||
md: Option<MarkdownView>,
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
file_order: VecDeque<String>,
|
||||
preview_bytes: usize,
|
||||
loading_files: HashSet<String>,
|
||||
commits: HashMap<String, FileCommit>,
|
||||
pending_commits: Vec<String>,
|
||||
loading_commits: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoFilesView {
|
||||
pub(super) fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
tree_state: cx.new(|cx| TreeState::new(cx)),
|
||||
worktree: None,
|
||||
worktree_paths: Vec::new(),
|
||||
md: None,
|
||||
code: None,
|
||||
readme_name: None,
|
||||
selected_file: None,
|
||||
files: HashMap::new(),
|
||||
file_order: VecDeque::new(),
|
||||
preview_bytes: 0,
|
||||
loading_files: HashSet::new(),
|
||||
commits: HashMap::new(),
|
||||
pending_commits: Vec::new(),
|
||||
loading_commits: false,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_worktree(&mut self, path: PathBuf) {
|
||||
self.worktree = Some(path);
|
||||
}
|
||||
|
||||
pub(super) fn apply_entries(
|
||||
&mut self,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.worktree_paths = paths;
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(tree_items(tree, false), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Point the README pane at `path`/`bytes`, or clear it when absent.
|
||||
///
|
||||
/// Returns whether the pane changed.
|
||||
pub(super) fn set_readme(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some((path, bytes)) = path.zip(bytes) else {
|
||||
let changed = self.readme_name.is_some() || self.md.is_some();
|
||||
self.readme_name = None;
|
||||
self.md = None;
|
||||
return changed;
|
||||
};
|
||||
|
||||
let name: SharedString = path.to_string_lossy().into();
|
||||
let mut changed = self.readme_name.as_ref() != Some(&name);
|
||||
self.readme_name = Some(name);
|
||||
self.load_commit(&path.to_string_lossy(), cx);
|
||||
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
changed |= self.set_markdown(None, &text, cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drop every cached preview and the README, e.g. on a branch switch.
|
||||
pub(super) fn clear_previews(&mut self) {
|
||||
self.selected_file = None;
|
||||
self.files.clear();
|
||||
self.file_order.clear();
|
||||
self.preview_bytes = 0;
|
||||
self.loading_files.clear();
|
||||
self.commits.clear();
|
||||
self.pending_commits.clear();
|
||||
self.loading_commits = false;
|
||||
self.md = None;
|
||||
self.code = None;
|
||||
self.readme_name = None;
|
||||
}
|
||||
|
||||
/// Refresh after the mirror caught up with the remote.
|
||||
///
|
||||
/// Unlike a branch switch this keeps the selection and previews: it rebuilds
|
||||
/// the tree, drops previews of files the refresh removed and re-renders the
|
||||
/// README when it is on screen.
|
||||
///
|
||||
/// Returns whether the tree, a preview or the README changed.
|
||||
pub(super) fn catch_up(
|
||||
&mut self,
|
||||
snapshot: &WorktreeSnapshot,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if paths != self.worktree_paths {
|
||||
self.apply_entries(tree, paths, cx);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let present: HashSet<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut previewed: Vec<String> = Vec::new();
|
||||
|
||||
previewed.extend(self.files.keys().cloned());
|
||||
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
|
||||
|
||||
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
previewed.sort();
|
||||
previewed.dedup();
|
||||
|
||||
for path in previewed {
|
||||
if !present.contains(&path) {
|
||||
self.drop_preview_of(&path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if self.selected_file.is_none() {
|
||||
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn pane_title(&self) -> SharedString {
|
||||
self.selected_file
|
||||
.clone()
|
||||
.or_else(|| self.readme_name.clone())
|
||||
.unwrap_or_else(|| "Overview".into())
|
||||
}
|
||||
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1().min_h_0().child(tree(
|
||||
&tree_state,
|
||||
move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()), cx)
|
||||
} else {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None, cx)
|
||||
} else {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().muted)
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.xsmall()
|
||||
.text()
|
||||
.label(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("repo-content").flex_1().min_h_0().child(body))
|
||||
}
|
||||
|
||||
fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(md) = &self.md
|
||||
&& md.path == path
|
||||
&& md.source_hash == hash
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let state = cx.new(|cx| TextViewState::markdown("", cx));
|
||||
state.update(cx, |state, cx| state.push_str(text, cx));
|
||||
|
||||
self.md = Some(MarkdownView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
TextView::new(&md.state)
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(code) = &self.code
|
||||
&& code.path == path
|
||||
&& code.source_hash == hash
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let language = code_language(path.as_ref()).unwrap_or("text");
|
||||
let state = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
.language(language)
|
||||
.default_value(text)
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
Editor::new(&code.state)
|
||||
.readonly(true)
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
|
||||
if self.files.contains_key(path) {
|
||||
if let Some(FileContent::Text(text)) = self.files.get(path) {
|
||||
let text = text.clone();
|
||||
if is_markdown_path(path) {
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
|
||||
self.set_markdown(Some(path.into()), &text, cx);
|
||||
}
|
||||
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
|
||||
self.set_code(path.into(), &text, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.loading_files.contains(path) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let rel = Path::new(path);
|
||||
let unsafe_path = rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if unsafe_path {
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
self.load_commit(&path, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let full = worktree.join(&path_for_read);
|
||||
|
||||
let metadata = match std::fs::metadata(&full) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
|
||||
return Ok(FileContent::TooLarge);
|
||||
}
|
||||
|
||||
let bytes = match std::fs::read(&full) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(text) => Ok(FileContent::Text(text)),
|
||||
Err(_) => Ok(FileContent::Binary),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading_files.remove(&path);
|
||||
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
if let FileContent::Text(text) = &kind {
|
||||
if is_markdown_path(&path) {
|
||||
let same = this.md.as_ref().map(|md| md.path.as_deref())
|
||||
== Some(Some(path.as_str()));
|
||||
if !same {
|
||||
this.set_markdown(Some(path.clone().into()), text, cx);
|
||||
}
|
||||
} else {
|
||||
let same = this.code.as_ref().map(|code| code.path.as_str())
|
||||
== Some(path.as_str());
|
||||
if !same {
|
||||
this.set_code(path.clone().into(), text, window, cx);
|
||||
}
|
||||
}
|
||||
this.preview_bytes += text.len();
|
||||
}
|
||||
this.files.insert(path.clone(), kind);
|
||||
this.file_order.push_back(path);
|
||||
this.evict_previews();
|
||||
}
|
||||
Err(error) => {
|
||||
this.files
|
||||
.insert(path, FileContent::Failed(error.to_string()));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn drop_preview_of(&mut self, path: &str) {
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
self.commits.remove(path);
|
||||
|
||||
if self.selected_file.as_deref() == Some(path) {
|
||||
self.selected_file = None;
|
||||
}
|
||||
|
||||
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
|
||||
self.md = None;
|
||||
}
|
||||
|
||||
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
|
||||
self.code = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_previews(&mut self) {
|
||||
while (self.files.len() > MAX_PREVIEWED_FILES
|
||||
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
|
||||
&& self.file_order.len() > 1
|
||||
{
|
||||
let path = self.file_order.pop_front().expect("non-empty");
|
||||
|
||||
if Some(path.as_str()) == self.selected_file.as_deref() {
|
||||
self.file_order.push_back(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
|
||||
self.md = None;
|
||||
}
|
||||
|
||||
if self
|
||||
.code
|
||||
.as_ref()
|
||||
.is_some_and(|code| code.path.as_ref() == path.as_str())
|
||||
{
|
||||
self.code = None;
|
||||
}
|
||||
|
||||
self.commits.remove(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_commits.push(path.to_string());
|
||||
|
||||
if !self.loading_commits {
|
||||
self.load_commits(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commits(&mut self, cx: &mut Context<Self>) {
|
||||
if self.pending_commits.is_empty() || self.loading_commits {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
self.pending_commits.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_commits = true;
|
||||
|
||||
let paths = std::mem::take(&mut self.pending_commits);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels))
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.loading_commits = false;
|
||||
|
||||
if let Ok(found) = result {
|
||||
for (path, commit) in found {
|
||||
this.commits
|
||||
.insert(path.to_string_lossy().into_owned(), commit);
|
||||
}
|
||||
}
|
||||
|
||||
if !this.pending_commits.is_empty() {
|
||||
this.load_commits(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoFilesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
let pane_title = self.pane_title();
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(Self::render_tree_column(tree_state, view, cx))
|
||||
.child(self.render_content_column(pane_title, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn source_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Some common files are recognized by name rather than extension.
|
||||
match name {
|
||||
"Makefile" | "makefile" => return Some("make"),
|
||||
"CMakeLists.txt" => return Some("cmake"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"rs" => "rust",
|
||||
"toml" => "toml",
|
||||
"json" | "jsonc" => "json",
|
||||
"py" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" | "jsx" => "tsx",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"java" => "java",
|
||||
"kt" | "kts" | "ktm" => "kotlin",
|
||||
"swift" => "swift",
|
||||
"php" | "phtml" => "php",
|
||||
"rb" => "ruby",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"yml" | "yaml" => "yaml",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"html" | "htm" => "html",
|
||||
"lua" => "lua",
|
||||
"sql" => "sql",
|
||||
"proto" | "protobuf" => "proto",
|
||||
"cmake" => "cmake",
|
||||
"zig" => "zig",
|
||||
"ex" | "exs" => "elixir",
|
||||
"graphql" | "gql" => "graphql",
|
||||
"diff" | "patch" => "diff",
|
||||
"svelte" => "svelte",
|
||||
"astro" => "astro",
|
||||
"scala" => "scala",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
|
||||
use signed_git::{CommitList, Repo};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::placeholder;
|
||||
|
||||
use super::repo_display_name;
|
||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row};
|
||||
|
||||
pub(super) struct RepoHistoryView {
|
||||
store: Entity<RepoStore>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
worktree: Option<PathBuf>,
|
||||
all_commits: Option<CommitList>,
|
||||
loading_all_commits: bool,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoHistoryView {
|
||||
pub(super) fn new(store: Entity<RepoStore>, dock_area: WeakEntity<DockArea>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
worktree: None,
|
||||
all_commits: None,
|
||||
loading_all_commits: false,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_worktree(&mut self, path: Option<PathBuf>) {
|
||||
self.worktree = path;
|
||||
}
|
||||
|
||||
/// Number of commits reachable from HEAD, for the Commits tab badge.
|
||||
pub(super) fn commit_count(&self) -> Option<usize> {
|
||||
self.all_commits.as_ref().map(|list| list.total)
|
||||
}
|
||||
|
||||
/// Drop the current list and walk HEAD again.
|
||||
pub(super) fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.all_commits = None;
|
||||
self.loading_all_commits = false;
|
||||
self.load(cx);
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading_all_commits || self.all_commits.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_all_commits = true;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
Repo::open_cached(&worktree).and_then(|repo| repo.all_commits())
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if let Ok(list) = result {
|
||||
let count = list.commits.len();
|
||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||
this.all_commits = Some(list);
|
||||
}
|
||||
|
||||
this.loading_all_commits = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub(super) fn open_commit_diff(
|
||||
&mut self,
|
||||
commit_id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Same display name as the repo detail panel's title.
|
||||
let repo_name = repo_display_name(self.store.read(cx));
|
||||
|
||||
let store = self.store.clone();
|
||||
let panel = cx.new(|cx| {
|
||||
CommitDiffView::new(store, worktree, repo_name, commit_id.into(), window, cx)
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoHistoryView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window, px};
|
||||
use gpui_base::h_flex;
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::select::{Select, SelectState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_git::Repo;
|
||||
use signed_state::Backend;
|
||||
use signed_ui::SelectOption;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row};
|
||||
use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
|
||||
pub type InitRepoState = DialogProgress;
|
||||
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let default_name = local_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let grasp_settings = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.clone();
|
||||
|
||||
let state = cx.new(|_| InitRepoState::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
||||
let desc_input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(3, 5)
|
||||
.placeholder("Short description")
|
||||
});
|
||||
let branch_select: Entity<SelectState<Vec<SelectOption>>> =
|
||||
cx.new(|cx| SelectState::new(Vec::new(), None, window, cx));
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
load_branches(local_path.clone(), branch_select.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
const DESC: &str = "Publish this local repository to Nostr.";
|
||||
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
let branch_select = branch_select.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
let view = view.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Initialize repository"))
|
||||
.child(DialogDescription::new().child(DESC)),
|
||||
)
|
||||
.child(
|
||||
v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Repository name")
|
||||
.description("Max 100 characters")
|
||||
.required(true)
|
||||
.child(Input::new(&name_input).readonly(true)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Description")
|
||||
.child(Textarea::new(&desc_input)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Folder")
|
||||
.description("The local repository being published")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().muted)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(local_path.display().to_string()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Default branch")
|
||||
.description("The local branch to announce as the default")
|
||||
.child(Select::new(&branch_select).w_full()),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(push_steps_row(&state.read(cx).push_steps, cx))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("init")
|
||||
.primary()
|
||||
.label("Initialize")
|
||||
.icon(CustomIconName::Init)
|
||||
.tooltip("Publish to Nostr")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let branch_select = branch_select.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
let view = view.clone();
|
||||
|
||||
move |_ev, window, cx| {
|
||||
init_repository(
|
||||
local_path.clone(),
|
||||
(name_input.clone(), desc_input.clone()),
|
||||
branch_select.clone(),
|
||||
state.clone(),
|
||||
grasp_state.clone(),
|
||||
view.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn init_repository(
|
||||
local_path: PathBuf,
|
||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||
branch_select: Entity<SelectState<Vec<SelectOption>>>,
|
||||
state: Entity<InitRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let (name_input, desc_input) = inputs;
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
let description = desc_input.read(cx).value().trim().to_owned();
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
let default_branch = branch_select
|
||||
.read(cx)
|
||||
.selected_value()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|branch| !branch.is_empty());
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
state.update(cx, |state, _| state.fail("Sign in to publish a repository"));
|
||||
return;
|
||||
};
|
||||
|
||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
||||
state.update(cx, |state, cx| state.begin_push(addr, cx));
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.publish_local_repo(
|
||||
local_path.clone(),
|
||||
&name,
|
||||
&description,
|
||||
servers,
|
||||
default_branch,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = state.clone();
|
||||
let view = view.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(announcement) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.apply_announcement(announcement, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Fills the default-branch selector with the repository's local branches,
|
||||
/// preselecting the checked-out one.
|
||||
fn load_branches(
|
||||
local_path: PathBuf,
|
||||
select: Entity<SelectState<Vec<SelectOption>>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let loaded = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&local_path)?;
|
||||
let current: Option<SharedString> = repo.current_branch().map(Into::into);
|
||||
Ok::<_, anyhow::Error>((repo.branches()?, current))
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = cx.update_window(handle, |_, window, cx| {
|
||||
let Ok((branches, current)) = loaded.map_err(|error| {
|
||||
log::warn!("failed to list branches for the default branch selector: {error:#}")
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let options: Vec<SelectOption> = branches
|
||||
.into_iter()
|
||||
.map(|branch| SelectOption::new(branch.clone(), branch))
|
||||
.collect();
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
state.set_items(options, window, cx);
|
||||
if let Some(current) = current.as_ref() {
|
||||
state.set_selected_value(current, window, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,115 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window};
|
||||
use gpui_component::combobox::ComboboxState;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
|
||||
pub(super) struct RefSwitcher {
|
||||
pub(super) branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
pub(super) tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
ref_branches: Vec<SharedString>,
|
||||
ref_tags: Vec<SharedString>,
|
||||
pub(super) switching_ref: bool,
|
||||
}
|
||||
|
||||
impl RefSwitcher {
|
||||
pub(super) fn new(window: &mut Window, cx: &mut App) -> Self {
|
||||
let branch_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
let tag_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
|
||||
Self {
|
||||
branch_select,
|
||||
tag_select,
|
||||
ref_branches: Vec::new(),
|
||||
ref_tags: Vec::new(),
|
||||
switching_ref: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_branches(
|
||||
&mut self,
|
||||
branches: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
selected,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn set_tags(
|
||||
&mut self,
|
||||
tags: Vec<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
|
||||
}
|
||||
|
||||
pub(super) fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
select.update(cx, |state, cx| match previous {
|
||||
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
|
||||
None => state.clear_selection(cx),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_selector(
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_items(SearchableVec::from(items.clone()), window, cx);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected_values(std::slice::from_ref(&value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{RepoListStore, Timestamp};
|
||||
use signed_ui::{Avatar, SegmentButton};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::{open_repo_panel, panel_avatar, tab_title};
|
||||
|
||||
const COLUMNS: usize = 2;
|
||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||
|
||||
const RECENT_COUNT: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum RepoFilter {
|
||||
All,
|
||||
#[default]
|
||||
Popular,
|
||||
Recent,
|
||||
}
|
||||
|
||||
impl AsRef<str> for RepoFilter {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
RepoFilter::All => "all",
|
||||
RepoFilter::Popular => "popular",
|
||||
RepoFilter::Recent => "recent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RepoFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoFilter {
|
||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||
///
|
||||
/// Narrowed to repositories whose name or id contains `query`.
|
||||
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
||||
let announcements = &store.announcements;
|
||||
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
||||
|
||||
// Narrow by the search query first.
|
||||
// Recent then limits the matches and Popular ranks them.
|
||||
let query = query.trim().to_lowercase();
|
||||
|
||||
if !query.is_empty() {
|
||||
indices.retain(|&ix| {
|
||||
let announcement = &announcements[ix];
|
||||
let name = announcement.name.as_deref().unwrap_or(&announcement.id);
|
||||
name.to_lowercase().contains(&query)
|
||||
});
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::All => {}
|
||||
Self::Recent => indices.truncate(RECENT_COUNT),
|
||||
Self::Popular => {
|
||||
let counts = &store.counts;
|
||||
let scores: Vec<u32> = announcements
|
||||
.iter()
|
||||
.map(|a| counts.get(&a.addr()).map_or(0, |c| c.score()))
|
||||
.collect();
|
||||
indices.sort_by(|&a, &b| scores[b].cmp(&scores[a]));
|
||||
}
|
||||
}
|
||||
|
||||
indices
|
||||
}
|
||||
|
||||
fn icon_name(self) -> CustomIconName {
|
||||
match self {
|
||||
Self::All => CustomIconName::Grid,
|
||||
Self::Recent => CustomIconName::Recent,
|
||||
Self::Popular => CustomIconName::Trending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RepoListView {
|
||||
store: Entity<RepoListStore>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
filter: RepoFilter,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
|
||||
repo_len: usize,
|
||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||
visible: Vec<usize>,
|
||||
search: Entity<InputState>,
|
||||
/// Profiles for the card owners.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_search_subscription: Subscription,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let store = RepoListStore::global(cx);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
this.rebuild_rows(cx);
|
||||
}
|
||||
});
|
||||
|
||||
// Rebuild the rows when the store changes.
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
focus_handle: cx.focus_handle(),
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
filter: RepoFilter::default(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
repo_len: 0,
|
||||
visible: Vec::new(),
|
||||
search,
|
||||
directory,
|
||||
_search_subscription: search_subscription,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
let query = self.search.read(cx).value();
|
||||
let store = self.store.read(cx);
|
||||
|
||||
self.visible = filter.visible(store, &query);
|
||||
|
||||
let owners: Vec<PublicKey> = self
|
||||
.visible
|
||||
.iter()
|
||||
.filter_map(|ix| store.announcements.get(*ix))
|
||||
.map(|announcement| announcement.owner)
|
||||
.collect();
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(owners, cx);
|
||||
});
|
||||
|
||||
// Each virtual list row holds `COLUMNS` repo cards.
|
||||
let rows = self.visible.len().div_ceil(COLUMNS);
|
||||
|
||||
if self.repo_len != rows {
|
||||
self.repo_len = rows;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_card(
|
||||
&self,
|
||||
ix: usize,
|
||||
announcement: &Announcement,
|
||||
last_activity: Option<Timestamp>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from(announcement.id.clone()));
|
||||
|
||||
let description = announcement
|
||||
.description
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("No description"));
|
||||
|
||||
let activity = last_activity
|
||||
.map(relative_time)
|
||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||
.unwrap_or_default();
|
||||
|
||||
// The fork badge shows the upstream name when its announcement is known locally.
|
||||
// Otherwise it shows the repository id from the `u` tag.
|
||||
let fork_label: Option<SharedString> =
|
||||
announcement.upstream.as_ref().and_then(|upstream| {
|
||||
let addr = upstream.addr.as_ref()?;
|
||||
let name = self
|
||||
.store
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||
Some(SharedString::from(format!("forked from {name}")))
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.id(ix)
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.min_w_0()
|
||||
.px_3()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when_some(fork_label, |this, label| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(Icon::new(CustomIconName::GitBranch).small())
|
||||
.child(label),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_16()
|
||||
.min_w_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_clamp(2)
|
||||
.text_ellipsis()
|
||||
.child(description),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_12()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner.name())
|
||||
.picture(owner.picture()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(owner.name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.justify_end()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(activity),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener({
|
||||
let announcement = announcement.clone();
|
||||
move |this, _ev, window, cx| {
|
||||
open_repo_panel(
|
||||
&this.dock_area,
|
||||
&announcement.addr(),
|
||||
Some(&announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_filter<T>(&self, filter: RepoFilter, label: T, cx: &mut Context<Self>) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(filter.to_string(), label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.line_height(relative(1.2))
|
||||
.child("Repositories"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::from(format!("Total: {count}"))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Input::new(&self.search)
|
||||
.cleanable(true)
|
||||
.w(px(180.))
|
||||
.text_sm()
|
||||
.border_color(cx.theme().muted)
|
||||
.bg(cx.theme().muted)
|
||||
.prefix(Icon::new(IconName::Search).small()),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.render_filter(RepoFilter::All, "All", cx))
|
||||
.child(self.render_filter(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.render_filter(RepoFilter::Recent, "Recent", cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for RepoListView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"repo_list"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RepoListView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
tab_title(panel_avatar("explore"), SharedString::from("Explore"))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RepoListView {}
|
||||
|
||||
impl Focusable for RepoListView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoListView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let announcements = store.announcements.clone();
|
||||
let last_activity = store.last_activity.clone();
|
||||
let count = self.visible.len();
|
||||
let has_repos = count > 0;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.image_cache(gpui::retain_all("repos"))
|
||||
.size_full()
|
||||
.child(self.render_header(count, cx))
|
||||
.when(!has_repos, |this| {
|
||||
this.child(
|
||||
v_flex().size_full().items_center().justify_center().child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("No repositories found yet."),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(has_repos, |this| {
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
|
||||
this.child(
|
||||
v_virtual_list(view, "repos", sizes, move |this, range, _window, cx| {
|
||||
let mut items = vec![];
|
||||
|
||||
for row in range {
|
||||
let mut row_cards = vec![];
|
||||
|
||||
for col in 0..COLUMNS {
|
||||
let Some(&ix) = this.visible.get(row * COLUMNS + col) else {
|
||||
break;
|
||||
};
|
||||
let Some(announcement) = announcements.get(ix) else {
|
||||
break;
|
||||
};
|
||||
let activity = last_activity.get(&announcement.addr()).copied();
|
||||
row_cards.push(this.render_card(ix, announcement, activity, cx));
|
||||
}
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(row)
|
||||
.w_full()
|
||||
.h_full()
|
||||
.gap_3()
|
||||
.px_4()
|
||||
.pt_4()
|
||||
.children(row_cards)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
pub struct SendPatchView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
subject: Entity<InputState>,
|
||||
description: Entity<TextareaState>,
|
||||
patch: Entity<TextareaState>,
|
||||
submitting: bool,
|
||||
/// Error of the last submit attempt, it keeps the panel open.
|
||||
error: Option<SharedString>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
}
|
||||
|
||||
impl SendPatchView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||
let description =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change"));
|
||||
let patch = cx.new(|cx| {
|
||||
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
repo_name,
|
||||
subject,
|
||||
description,
|
||||
patch,
|
||||
submitting: false,
|
||||
error: None,
|
||||
directory,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting {
|
||||
return;
|
||||
}
|
||||
|
||||
let subject = self.subject.read(cx).value().to_string();
|
||||
let description = self.description.read(cx).value().to_string();
|
||||
let patch = self.patch.read(cx).value().to_string();
|
||||
|
||||
if patch.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = self.store.clone();
|
||||
let dock_area = self.dock_area.clone();
|
||||
let entity = cx.entity().clone();
|
||||
|
||||
self.submitting = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
// Errors the store detects before publishing.
|
||||
let sync_error = store.update(cx, |store, cx| {
|
||||
store.open_pull_request(
|
||||
(!subject.is_empty()).then_some(subject),
|
||||
description,
|
||||
None,
|
||||
patch,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
store.last_error.clone()
|
||||
});
|
||||
|
||||
if let Some(error) = sync_error {
|
||||
self.submitting = false;
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the panel once the publish is underway.
|
||||
cx.defer_in(window, {
|
||||
let dock_area = dock_area.clone();
|
||||
let entity = entity.clone();
|
||||
move |_, window, cx| {
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock, cx| {
|
||||
dock.remove_panel(entity, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_footer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let can_submit = !self.submitting
|
||||
&& !self.subject.read(cx).value().is_empty()
|
||||
&& !self.patch.read(cx).value().is_empty();
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.h_16()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
BaseButton::new("send-patch")
|
||||
.h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(cx.theme().primary)
|
||||
.text_color(cx.theme().primary_foreground)
|
||||
.hover(|this| this.bg(cx.theme().primary_hover))
|
||||
.active(|this| this.bg(cx.theme().primary_active))
|
||||
.map(|this| {
|
||||
if self.submitting {
|
||||
this.child(Spinner::new().small())
|
||||
} else {
|
||||
this.child(Icon::new(IconName::ArrowUp)).child("Send patch")
|
||||
}
|
||||
})
|
||||
.disabled(!can_submit)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(Input::new(&self.subject))
|
||||
.child(Textarea::new(&self.description).h(px(64.)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_patch(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
const MSG: &str = "You can paste a git diff or a git format-patch patch series here.";
|
||||
|
||||
v_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(MSG),
|
||||
)
|
||||
.child(Textarea::new(&self.patch).h_56())
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_send_patch_panel(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
|
||||
|
||||
let _ = dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
impl BasePanel for SendPatchView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"send-patch"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for SendPatchView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for SendPatchView {}
|
||||
|
||||
impl Focusable for SendPatchView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SendPatchView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.id("send-patch")
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.overflow_y_scrollbar()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.child(self.render_inputs(cx))
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.px_4()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error),
|
||||
)
|
||||
})
|
||||
.child(self.render_patch(cx)),
|
||||
)
|
||||
.child(self.render_footer(cx))
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dock::DockArea;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, WeakEntity, Window, div, px};
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_state::{Backend, CheckoutsStore};
|
||||
|
||||
use super::super::open_repo_panel;
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row};
|
||||
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
|
||||
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
.read(cx)
|
||||
.settings()
|
||||
.create_repository
|
||||
.default_folder
|
||||
.clone()
|
||||
.unwrap_or_else(paths::desktop_dir);
|
||||
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name"));
|
||||
let desc_input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(3, 5)
|
||||
.placeholder("Short description")
|
||||
});
|
||||
let folder_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(default_folder.to_string_lossy().to_string())
|
||||
});
|
||||
let relay_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
|
||||
});
|
||||
let state = cx.new(|_| CreateRepoState::default());
|
||||
let grasp_settings = settings.read(cx).settings().grasp_servers.clone();
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
const DESC: &str = "Publish a new repository to your grasp servers.";
|
||||
const FOLDER_NOTE: &str = "Where the repository's working copy is created.";
|
||||
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let folder_input = folder_input.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Create repository"))
|
||||
.child(DialogDescription::new().child(DESC)),
|
||||
)
|
||||
.child(
|
||||
v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Repository name")
|
||||
.description("Max 100 characters")
|
||||
.required(true)
|
||||
.child(Input::new(&name_input)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Description")
|
||||
.child(Textarea::new(&desc_input)),
|
||||
)
|
||||
.child(
|
||||
field().label("Folder").description(FOLDER_NOTE).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&folder_input).disabled(true)),
|
||||
)
|
||||
.child(
|
||||
Button::new("choose-folder")
|
||||
.icon(IconName::FolderOpen)
|
||||
.ghost()
|
||||
.tooltip("Choose folder")
|
||||
.on_click({
|
||||
let folder_input = folder_input.clone();
|
||||
move |_ev, window, cx| {
|
||||
choose_folder(&folder_input, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(push_steps_row(&state.read(cx).push_steps, cx))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("create")
|
||||
.primary()
|
||||
.label("Create repository")
|
||||
.icon(IconName::ArrowRight)
|
||||
.tooltip("Create repository")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let folder_input = folder_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
|
||||
move |_ev, window, cx| {
|
||||
create_repository(
|
||||
name_input.clone(),
|
||||
desc_input.clone(),
|
||||
folder_input.clone(),
|
||||
state.clone(),
|
||||
grasp_state.clone(),
|
||||
dock_area.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
let store = SettingsStore::global(cx);
|
||||
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Choose folder".into()),
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = prompt.await
|
||||
&& let Some(path) = paths.pop()
|
||||
{
|
||||
let path = path.to_string_lossy().to_string();
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.create_repository.default_folder = Some(path.clone().into())
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
folder_input.update(cx, |input, cx| input.set_value(path, window, cx));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Opens the new working copy and the repository panel on success.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_repository(
|
||||
name_input: Entity<InputState>,
|
||||
desc_input: Entity<TextareaState>,
|
||||
folder_input: Entity<InputState>,
|
||||
state: Entity<CreateRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
let description = desc_input.read(cx).value().trim().to_owned();
|
||||
let folder = PathBuf::from(folder_input.read(cx).value().trim());
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
state.update(cx, |state, _| state.fail("Sign in to create a repository"));
|
||||
return;
|
||||
};
|
||||
|
||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
||||
state.update(cx, |state, cx| state.begin_push(addr, cx));
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_repository(&name, &description, folder, servers, cx)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = state.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok((announcement, local_path)) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
// Record the new working copy as a checkout of this repository.
|
||||
// The New PR panel then pre-fills it.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.record(local_path.clone(), announcement.addr(), cx);
|
||||
});
|
||||
cx.open_with_system(&local_path);
|
||||
open_repo_panel(
|
||||
&dock_area,
|
||||
&announcement.addr(),
|
||||
Some(&announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window, div};
|
||||
use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
|
||||
use gpui_component::form::{Field, field};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
|
||||
use nostr::prelude::*;
|
||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::{normalize_server, server_host};
|
||||
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct GraspServersState {
|
||||
/// Set while the user's kind `10317` grasp list loads.
|
||||
pub loading_servers: bool,
|
||||
pub grasp_servers: Vec<RelayUrl>,
|
||||
pub servers_enabled: bool,
|
||||
/// Error from the last grasp-server edit, such as an invalid relay URL.
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl GraspServersState {
|
||||
/// Defaults used until the user's grasp list loads and replaces them.
|
||||
///
|
||||
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
|
||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
||||
DEFAULT_GRASP_SERVERS
|
||||
.iter()
|
||||
.map(|url| (*url).to_owned())
|
||||
.collect()
|
||||
} else {
|
||||
settings.default_servers.clone()
|
||||
};
|
||||
Self {
|
||||
loading_servers: true,
|
||||
servers_enabled: false,
|
||||
grasp_servers: urls
|
||||
.iter()
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Grasp servers form field shared by the publish dialogs.
|
||||
pub fn grasp_servers_field(
|
||||
state: &Entity<GraspServersState>,
|
||||
relay_input: &Entity<InputState>,
|
||||
cx: &App,
|
||||
) -> Field {
|
||||
const SERVER_NOTE: &str = "Where the repository is hosted";
|
||||
|
||||
let state = state.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
|
||||
let servers = state.read(cx).grasp_servers.clone();
|
||||
let loading_servers = state.read(cx).loading_servers;
|
||||
let servers_enabled = state.read(cx).servers_enabled;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
field()
|
||||
.label_fn({
|
||||
let state = state.clone();
|
||||
move |_window, cx| {
|
||||
let enabled = state.read(cx).servers_enabled;
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Toggle::new("grasp-servers-toggle")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon({
|
||||
if enabled {
|
||||
IconName::ChevronDown
|
||||
} else {
|
||||
IconName::ChevronUp
|
||||
}
|
||||
})
|
||||
.checked(enabled)
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |checked, _window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.servers_enabled = *checked;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(div().child("Grasp servers"))
|
||||
}
|
||||
})
|
||||
.when(servers_enabled, |this| this.description(SERVER_NOTE))
|
||||
.child(v_flex().gap_1().when(servers_enabled, |this| {
|
||||
this.children(
|
||||
servers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(div().flex_1().child(Input::new(&relay_input)))
|
||||
.child(
|
||||
Button::new("add-relay")
|
||||
.icon(IconName::Plus)
|
||||
.ghost()
|
||||
.tooltip("Add grasp server")
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
move |_ev, window, cx| {
|
||||
add_relay(&state, &relay_input, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(loading_servers, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Loading your grasp servers..."),
|
||||
)
|
||||
})
|
||||
.when_some(error, |this, error| {
|
||||
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_server_row(
|
||||
ix: usize,
|
||||
relay: &RelayUrl,
|
||||
state: Entity<GraspServersState>,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(server_host(relay)),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("remove-relay:{ix}"))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.flex_shrink_0()
|
||||
.tooltip("Remove")
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |_ev, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.grasp_servers.remove(ix);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match normalize_server(&value) {
|
||||
Some((_, relay)) => {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = None;
|
||||
if !state.grasp_servers.contains(&relay) {
|
||||
state.grasp_servers.push(relay);
|
||||
}
|
||||
});
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
None => {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the user's kind `10317` grasp list from the local database.
|
||||
///
|
||||
/// Replaces the defaults when the list is non-empty.
|
||||
pub fn load_user_grasp_servers(
|
||||
state: Entity<GraspServersState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
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);
|
||||
return;
|
||||
};
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result = signed_state::user_grasp_list_servers(&client, user).await;
|
||||
|
||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.loading_servers = false;
|
||||
if let Ok(servers) = result
|
||||
&& !servers.is_empty()
|
||||
{
|
||||
state.grasp_servers = servers;
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyWindowHandle, App, Entity, Subscription, Task, Window, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Credential,
|
||||
/// An `nsec` was entered; ask for a passphrase to encrypt it with.
|
||||
Encrypt,
|
||||
/// An `ncryptsec` was entered; ask for its passphrase to decrypt it.
|
||||
Decrypt,
|
||||
}
|
||||
|
||||
/// The credential entered on the first step.
|
||||
enum Credential {
|
||||
Nsec(String),
|
||||
Ncryptsec(String),
|
||||
Bunker(String),
|
||||
}
|
||||
|
||||
impl Credential {
|
||||
fn parse(input: &str) -> Option<Self> {
|
||||
let input = input.trim();
|
||||
|
||||
if input.starts_with("nsec1") {
|
||||
Some(Self::Nsec(input.to_owned()))
|
||||
} else if input.starts_with("ncryptsec1") {
|
||||
Some(Self::Ncryptsec(input.to_owned()))
|
||||
} else if input.starts_with("bunker://") {
|
||||
Some(Self::Bunker(input.to_owned()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the import dialog, so the current step and async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct ImportState {
|
||||
stage: Stage,
|
||||
credential: String,
|
||||
progress: DialogProgress,
|
||||
/// Keeps the subscriptions alive while the dialog is open.
|
||||
subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let secret_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("secret key or bunker://"));
|
||||
|
||||
let pass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Passphrase to protect your key")
|
||||
.masked(true)
|
||||
});
|
||||
|
||||
let repass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Repeat passphrase")
|
||||
.masked(true)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = cx.new(|_| ImportState::default());
|
||||
|
||||
// Enter in any field submits the step the dialog is on.
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
for input in [&secret_input, &pass_input, &repass_input] {
|
||||
let secret_input = secret_input.clone();
|
||||
let pass_input = pass_input.clone();
|
||||
let repass_input = repass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
subscriptions.push(cx.subscribe(input, move |_input, event, cx| {
|
||||
if matches!(event, InputEvent::PressEnter { .. }) {
|
||||
submit(
|
||||
&secret_input,
|
||||
&pass_input,
|
||||
&repass_input,
|
||||
&state,
|
||||
&handle,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
state.update(cx, |state, _cx| {
|
||||
state.subscriptions = subscriptions;
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let secret_input = secret_input.clone();
|
||||
let pass_input = pass_input.clone();
|
||||
let repass_input = repass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |content, _window, cx| {
|
||||
let stage = state.read(cx).stage;
|
||||
let busy = state.read(cx).progress.busy;
|
||||
let error = state.read(cx).progress.error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Import identity"))
|
||||
.child(DialogDescription::new().child(match stage {
|
||||
Stage::Credential => {
|
||||
"Paste the secret key or bunker URI of an existing identity."
|
||||
}
|
||||
Stage::Encrypt => {
|
||||
"Choose a passphrase to encrypt your key on this device."
|
||||
}
|
||||
Stage::Decrypt => "Enter the passphrase used to encrypt this key.",
|
||||
})),
|
||||
)
|
||||
.child(match stage {
|
||||
Stage::Credential => v_form().child(
|
||||
field()
|
||||
.label("Secret key or bunker URI")
|
||||
.required(true)
|
||||
.child(Input::new(&secret_input)),
|
||||
),
|
||||
Stage::Encrypt => v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Passphrase")
|
||||
.required(true)
|
||||
.child(Input::new(&pass_input)),
|
||||
)
|
||||
.child(field().required(true).child(Input::new(&repass_input))),
|
||||
Stage::Decrypt => v_form().child(
|
||||
field()
|
||||
.label("Passphrase")
|
||||
.required(true)
|
||||
.child(Input::new(&pass_input)),
|
||||
),
|
||||
})
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("import")
|
||||
.primary()
|
||||
.label(match stage {
|
||||
Stage::Credential => "Continue",
|
||||
Stage::Encrypt => "Import identity",
|
||||
Stage::Decrypt => "Unlock",
|
||||
})
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let secret_input = secret_input.clone();
|
||||
let pass_input = pass_input.clone();
|
||||
let repass_input = repass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
move |_ev, _window, cx| {
|
||||
submit(
|
||||
&secret_input,
|
||||
&pass_input,
|
||||
&repass_input,
|
||||
&state,
|
||||
&handle,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn submit(
|
||||
secret_input: &Entity<InputState>,
|
||||
pass_input: &Entity<InputState>,
|
||||
repass_input: &Entity<InputState>,
|
||||
state: &Entity<ImportState>,
|
||||
handle: &AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if state.read(cx).progress.busy {
|
||||
return;
|
||||
}
|
||||
|
||||
match state.read(cx).stage {
|
||||
Stage::Credential => start_import(secret_input, state, handle, cx),
|
||||
Stage::Encrypt => import_nsec(pass_input, repass_input, state, handle, cx),
|
||||
Stage::Decrypt => import_ncryptsec(pass_input, state, handle, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
|
||||
fn start_import(
|
||||
secret_input: &Entity<InputState>,
|
||||
state: &Entity<ImportState>,
|
||||
handle: &AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let credential = match Credential::parse(&secret_input.read(cx).value()) {
|
||||
Some(credential) => credential,
|
||||
None => {
|
||||
state.update(cx, |state, _| {
|
||||
state
|
||||
.progress
|
||||
.fail("Enter an nsec, ncryptsec or bunker:// URI");
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match credential {
|
||||
Credential::Nsec(secret) => {
|
||||
state.update(cx, |state, _| {
|
||||
state.credential = secret;
|
||||
state.stage = Stage::Encrypt;
|
||||
state.progress.error = None;
|
||||
});
|
||||
}
|
||||
Credential::Ncryptsec(secret) => {
|
||||
state.update(cx, |state, _| {
|
||||
state.credential = secret;
|
||||
state.stage = Stage::Decrypt;
|
||||
state.progress.error = None;
|
||||
});
|
||||
}
|
||||
Credential::Bunker(uri) => {
|
||||
state.update(cx, |state, _| state.progress.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
|
||||
finish_import(task, state, handle, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
|
||||
fn import_nsec(
|
||||
pass_input: &Entity<InputState>,
|
||||
repass_input: &Entity<InputState>,
|
||||
state: &Entity<ImportState>,
|
||||
handle: &AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let nsec = state.read(cx).credential.clone();
|
||||
let pass = pass_input.read(cx).value().to_string();
|
||||
let repass = repass_input.read(cx).value().to_string();
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if pass != repass {
|
||||
state.update(cx, |state, _| {
|
||||
state.progress.fail("Passphrases do not match");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _cx| {
|
||||
state.progress.begin();
|
||||
});
|
||||
|
||||
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
|
||||
finish_import(task, state, handle, cx);
|
||||
}
|
||||
|
||||
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
|
||||
fn import_ncryptsec(
|
||||
pass_input: &Entity<InputState>,
|
||||
state: &Entity<ImportState>,
|
||||
handle: &AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let ncryptsec = state.read(cx).credential.clone();
|
||||
let pass = pass_input.read(cx).value().to_string();
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _cx| {
|
||||
state.progress.begin();
|
||||
});
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.import_ncryptsec(&ncryptsec, &pass, cx)
|
||||
});
|
||||
finish_import(task, state, handle, cx);
|
||||
}
|
||||
|
||||
/// Runs the import task to completion, closing the dialog on success.
|
||||
fn finish_import<T: 'static>(
|
||||
task: Task<Result<T, anyhow::Error>>,
|
||||
state: &Entity<ImportState>,
|
||||
handle: &AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let handle = *handle;
|
||||
let state = state.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(_) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| state.progress.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,942 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{
|
||||
BasePanel, DockArea, Panel, PanelEvent, TAB_BAR_HEIGHT, add_center_panel, panel_handle,
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit,
|
||||
Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list,
|
||||
white,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_component::dialog::DialogButtonProps;
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::menu::{DropdownMenu, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex};
|
||||
use metadata::{Profile, ProfileDirectory};
|
||||
use nostr::prelude::{PublicKey, RelayUrl};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
||||
ResolvedLocalRepo, SyncStatusDirectory,
|
||||
};
|
||||
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||
|
||||
use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
mod create_repo_dialog;
|
||||
pub(crate) mod grasp_servers;
|
||||
mod import_dialog;
|
||||
mod onboarding_dialog;
|
||||
pub(crate) mod passphrase_dialog;
|
||||
mod settings_dialog;
|
||||
|
||||
use self::onboarding_dialog::OnboardingState;
|
||||
|
||||
/// The platform that bound a repository, `"nak"` or `"ngit"`.
|
||||
fn local_platform(binding: &Nip34Binding) -> Option<&'static str> {
|
||||
let signals = &binding.signals;
|
||||
|
||||
if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs {
|
||||
Some("nak")
|
||||
} else if signals.nostr_repo_config {
|
||||
Some("ngit")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
inbox: Option<WeakEntity<InboxView>>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
profile: Option<WeakEntity<PlaceholderPanel>>,
|
||||
relays: Option<WeakEntity<PlaceholderPanel>>,
|
||||
banner: SharedString,
|
||||
scanning: bool,
|
||||
/// User's announced repositories.
|
||||
announcements: Arc<Vec<Announcement>>,
|
||||
/// Local repositories found by the scan that are not announced yet.
|
||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||
/// Unpushed commit counts per announced repository.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
/// Profiles rendered by the sidebar.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
/// Sync statuses for the announced repositories.
|
||||
sync_directory: Entity<SyncStatusDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SidebarPanel {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let repos = RepoListStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if this.refresh(cx) || signer_changed {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Push statuses are recomputed in the background, so only the badge counts change.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_unpushed(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
inbox: None,
|
||||
explore: None,
|
||||
profile: None,
|
||||
relays: None,
|
||||
banner: pick_banner(),
|
||||
announcements: Arc::new(Vec::new()),
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
sync_directory: SyncStatusDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let backend = Backend::global(cx);
|
||||
let user = backend.read(cx).current_user();
|
||||
|
||||
let (announcements, local_repos, scanning) = {
|
||||
let repo_list = RepoListStore::global(cx).read(cx);
|
||||
|
||||
let announcements = user
|
||||
.as_ref()
|
||||
.map(|user| repo_list.announcements_of(user))
|
||||
.unwrap_or_default();
|
||||
|
||||
let local = LocalReposStore::global(cx).read(cx);
|
||||
let local_repos =
|
||||
LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements);
|
||||
|
||||
(announcements, local_repos, local.scanning)
|
||||
};
|
||||
|
||||
let announcements_changed = *self.announcements != announcements;
|
||||
let local_changed = *self.local_repos != local_repos;
|
||||
let scanning_changed = self.scanning != scanning;
|
||||
|
||||
self.announcements = Arc::new(announcements);
|
||||
self.local_repos = Arc::new(local_repos);
|
||||
self.scanning = scanning;
|
||||
|
||||
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
|
||||
let owners: Vec<PublicKey> = self
|
||||
.local_repos
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.nip34
|
||||
.as_ref()
|
||||
.and_then(|binding| binding.owner)
|
||||
.filter(|owner| !profiles.contains(owner))
|
||||
})
|
||||
.collect();
|
||||
|
||||
profiles.extend(owners);
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(profiles, cx);
|
||||
});
|
||||
|
||||
let addrs: Vec<RepoAddr> = self
|
||||
.announcements
|
||||
.iter()
|
||||
.map(|announcement| announcement.addr())
|
||||
.collect();
|
||||
|
||||
self.sync_directory.update(cx, |directory, cx| {
|
||||
directory.track(addrs, cx);
|
||||
});
|
||||
|
||||
if announcements_changed {
|
||||
self.request_push_watches(cx);
|
||||
self.unpushed.clear();
|
||||
}
|
||||
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
}
|
||||
|
||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.read(cx).unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
}
|
||||
|
||||
if unpushed == self.unpushed {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.unpushed = unpushed;
|
||||
true
|
||||
}
|
||||
|
||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
for announcement in self.announcements.iter() {
|
||||
checkouts.request_push_statuses(&announcement.addr(), cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx));
|
||||
self.inbox = Some(panel.downgrade());
|
||||
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.explore
|
||||
.as_ref()
|
||||
.and_then(WeakEntity::upgrade)
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx));
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn open_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
Self::open_placeholder_panel(
|
||||
&self.dock_area,
|
||||
&mut self.profile,
|
||||
"profile",
|
||||
"Profile",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
Self::open_placeholder_panel(
|
||||
&self.dock_area,
|
||||
&mut self.relays,
|
||||
"relays",
|
||||
"Relays",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens one of the not-yet-built panels, reusing the already-open one.
|
||||
fn open_placeholder_panel(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
slot: &mut Option<WeakEntity<PlaceholderPanel>>,
|
||||
name: &'static str,
|
||||
title: &'static str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if slot.as_ref().and_then(WeakEntity::upgrade).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let panel = cx.new(|cx| PlaceholderPanel::new(name, title, cx));
|
||||
*slot = Some(panel.downgrade());
|
||||
|
||||
dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name"));
|
||||
let pass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Passphrase to protect your keys")
|
||||
.masked(true)
|
||||
});
|
||||
let repass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Repeat passphrase")
|
||||
.masked(true)
|
||||
});
|
||||
let state = cx.new(|_| OnboardingState::default());
|
||||
|
||||
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
|
||||
}
|
||||
|
||||
/// The detail view offers to publish it to NIP-34.
|
||||
fn open_local_repo(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
nip34: Option<Nip34Binding>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let detail =
|
||||
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, nip34, window, cx));
|
||||
self.add_detail_panel(detail, window, cx);
|
||||
}
|
||||
|
||||
/// A local repository whose binding matches an announcement opens as the announced repository.
|
||||
fn open_local_announced(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let detail = cx.new(|cx| {
|
||||
RepoDetailView::new_local_announced(
|
||||
self.dock_area.clone(),
|
||||
announcement,
|
||||
path,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
self.add_detail_panel(detail, window, cx);
|
||||
}
|
||||
|
||||
/// A local repository opens as the announced repository
|
||||
/// when its binding matches one, and as a local-only repository otherwise.
|
||||
fn open_local_entry(
|
||||
&mut self,
|
||||
entry: ResolvedLocalRepo,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ResolvedLocalRepo {
|
||||
path,
|
||||
nip34,
|
||||
announcement,
|
||||
} = entry;
|
||||
|
||||
if let Some(announcement) = announcement {
|
||||
self.open_local_announced(announcement, path, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_local_repo(path, nip34, window, cx);
|
||||
}
|
||||
|
||||
fn add_detail_panel(
|
||||
&mut self,
|
||||
detail: Entity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(detail), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let announcements = self.announcements.clone();
|
||||
let local_repos = self.local_repos.clone();
|
||||
let scanning = self.scanning;
|
||||
|
||||
v_flex()
|
||||
.px_2()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.w_full()
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Filter).small())
|
||||
.child(div().text_xs().font_semibold().child("All Repositories")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("rescan")
|
||||
.icon(CustomIconName::Refresh)
|
||||
.small()
|
||||
.ghost()
|
||||
.tooltip("Rescan for local repositories")
|
||||
.on_click(cx.listener(|_this, _ev, _window, cx| {
|
||||
let local_repos = LocalReposStore::global(cx);
|
||||
local_repos.update(cx, |store, cx| store.rescan(cx));
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||
create_repo_dialog::open(
|
||||
this.dock_area.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
// The merged list: NIP-34 repositories first, then discovered local repositories.
|
||||
let total = announcements.len() + local_repos.len();
|
||||
|
||||
if total == 0 {
|
||||
this.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.map(|this| {
|
||||
if scanning {
|
||||
this.child("Scanning for local repositories…")
|
||||
} else {
|
||||
this.child("No repositories yet")
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
"repos",
|
||||
total,
|
||||
cx.processor(move |this, range: Range<usize>, _, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
this.render_repo_at(&announcements, &local_repos, ix, cx)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
)
|
||||
.flex_1()
|
||||
.min_h_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_repo_at(
|
||||
&self,
|
||||
announcements: &[Announcement],
|
||||
local_repos: &[ResolvedLocalRepo],
|
||||
ix: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if ix < announcements.len() {
|
||||
return self
|
||||
.render_repo_row(&announcements[ix], cx)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let local_ix = ix - announcements.len();
|
||||
let entry = &local_repos[local_ix];
|
||||
|
||||
self.render_local_row(entry, cx).into_any_element()
|
||||
}
|
||||
|
||||
fn render_repo_row(
|
||||
&self,
|
||||
announcement: &Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = announcement.name().map(SharedString::from);
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
let avatar = Avatar::new(announcement.owner)
|
||||
.name(owner.name())
|
||||
.picture(owner.picture())
|
||||
.xsmall();
|
||||
|
||||
let announcement = announcement.clone();
|
||||
let addr = announcement.addr();
|
||||
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
|
||||
let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx);
|
||||
|
||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||
|
||||
if unpushed > 0 || unsynced > 0 {
|
||||
let badges = h_flex().gap_0p5().flex_shrink_0();
|
||||
|
||||
let badges = if unpushed > 0 {
|
||||
badges.child(Self::count_badge(unpushed, cx.theme().red_light))
|
||||
} else {
|
||||
badges
|
||||
};
|
||||
|
||||
let badges = if unsynced > 0 {
|
||||
badges.child(Self::count_badge(unsynced, cx.theme().info))
|
||||
} else {
|
||||
badges
|
||||
};
|
||||
|
||||
row = row.suffix(badges);
|
||||
}
|
||||
|
||||
row.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
open_repo_panel(
|
||||
&this.dock_area,
|
||||
&announcement.addr(),
|
||||
Some(&announcement),
|
||||
window,
|
||||
&mut *cx,
|
||||
);
|
||||
}))
|
||||
}
|
||||
|
||||
fn count_badge(count: usize, color: Hsla) -> Div {
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.size_4()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.line_height(relative(1.))
|
||||
.bg(color)
|
||||
.text_color(white())
|
||||
.text_size(px(8.))
|
||||
.child(SharedString::from(count.to_string()))
|
||||
}
|
||||
|
||||
fn render_local_row(
|
||||
&self,
|
||||
entry: &ResolvedLocalRepo,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = entry.name();
|
||||
let avatar = local_avatar(entry, &self.directory, cx);
|
||||
|
||||
let id = format!("local-repo:{}", entry.path.display());
|
||||
let entry = entry.clone();
|
||||
|
||||
let suffix: AnyElement = match entry.nip34.as_ref() {
|
||||
Some(binding) => {
|
||||
let (label, tooltip) = match binding.kind {
|
||||
Nip34Kind::Initialized => {
|
||||
let platform = local_platform(binding).unwrap_or("Grasp");
|
||||
let tooltip = match platform {
|
||||
"nak" => "Initialized with nak",
|
||||
"ngit" => "Initialized with ngit",
|
||||
_ => "Initialized for NIP-34",
|
||||
};
|
||||
(platform, tooltip)
|
||||
}
|
||||
Nip34Kind::Cloned => ("Cloned", "Cloned buts not initialized"),
|
||||
Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"),
|
||||
};
|
||||
|
||||
Button::new(id.clone())
|
||||
.xsmall()
|
||||
.child(div().text_size(px(10.)).child(label))
|
||||
.tooltip(tooltip)
|
||||
.secondary()
|
||||
.into_any_element()
|
||||
}
|
||||
None => Button::new(id.clone())
|
||||
.xsmall()
|
||||
.icon(IconName::TriangleAlert)
|
||||
.tooltip("Not published yet")
|
||||
.ghost()
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
NavItem::new(id, name, avatar)
|
||||
.suffix(suffix)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open_local_entry(entry.clone(), window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
/// The user avatar and name, wired into the titlebar drag area.
|
||||
fn render_user(
|
||||
&self,
|
||||
profile: &Profile,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
let panel = cx.entity().downgrade();
|
||||
|
||||
title_bar_drag_handlers(
|
||||
h_flex()
|
||||
.id("user")
|
||||
.h(TAB_BAR_HEIGHT)
|
||||
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
||||
.child(
|
||||
div().child(
|
||||
Button::new("user")
|
||||
.text()
|
||||
.dropdown_caret(true)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(profile.public_key())
|
||||
.name(name.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(div().text_xs().font_semibold().child(name)),
|
||||
)
|
||||
.dropdown_menu(move |menu, _, _| {
|
||||
menu.item(PopupMenuItem::new("View Profile").on_click({
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
let _ = panel.update(cx, |this, cx| {
|
||||
this.open_profile(window, cx);
|
||||
});
|
||||
}
|
||||
}))
|
||||
.item(PopupMenuItem::new("View Relays").on_click({
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
let _ = panel.update(cx, |this, cx| {
|
||||
this.open_relays(window, cx);
|
||||
});
|
||||
}
|
||||
}))
|
||||
.separator()
|
||||
.item(
|
||||
PopupMenuItem::element(|_, cx| {
|
||||
div().text_color(cx.theme().danger).child("Exit")
|
||||
})
|
||||
.on_click(
|
||||
|_, window, cx| {
|
||||
open_exit_dialog(window, cx);
|
||||
},
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shown while no identity is signed in.
|
||||
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(title_bar_drag_handlers(
|
||||
div()
|
||||
.id("onboarding-drag")
|
||||
.absolute()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.top_0()
|
||||
.left_0(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
div().absolute().inset_0().child(
|
||||
img(self.banner.clone())
|
||||
.size_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_end()
|
||||
.p_4()
|
||||
.mb_4()
|
||||
.gap_4()
|
||||
.child(img("backgrounds/headline.png").max_w_48())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(
|
||||
BaseButton::new("onboarding")
|
||||
.h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.bg(cx.theme().primary)
|
||||
.hover(|this| this.bg(cx.theme().primary_hover))
|
||||
.active(|this| this.bg(cx.theme().primary_active))
|
||||
.text_color(cx.theme().primary_foreground)
|
||||
.child(div().text_sm().font_semibold().child("Join now"))
|
||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||
this.open_onboarding(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("import")
|
||||
.h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.text_color(gpui::white())
|
||||
.bg(gpui::white().opacity(0.1))
|
||||
.hover(|this| this.bg(gpui::white().opacity(0.2)))
|
||||
.active(|this| this.bg(gpui::white().opacity(0.4)))
|
||||
.child(div().text_sm().child("Import identity"))
|
||||
.on_click(cx.listener(|_this, _ev, window, cx| {
|
||||
import_dialog::open(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
||||
///
|
||||
/// Returns the text to store and the parsed relay URL, or `None` when it is not a valid relay URL.
|
||||
pub(super) fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
|
||||
let text = if input.contains("://") {
|
||||
input.to_owned()
|
||||
} else {
|
||||
format!("wss://{input}")
|
||||
};
|
||||
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
|
||||
}
|
||||
|
||||
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
|
||||
pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
/// The bound owner's avatar, falling back to the repo's pixel avatar when unowned.
|
||||
fn local_avatar(
|
||||
entry: &ResolvedLocalRepo,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||
return Avatar::new(entry.path.to_string_lossy())
|
||||
.xsmall()
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let profile = directory.read(cx).profile(&owner, cx);
|
||||
|
||||
Avatar::new(owner)
|
||||
.name(profile.name())
|
||||
.picture(profile.picture())
|
||||
.xsmall()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn pick_banner() -> SharedString {
|
||||
let num = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock before unix epoch")
|
||||
.subsec_nanos()
|
||||
% 3
|
||||
+ 1;
|
||||
format!("backgrounds/banner{num}.jpg").into()
|
||||
}
|
||||
|
||||
impl BasePanel for SidebarPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"sidebar"
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for SidebarPanel {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for SidebarPanel {}
|
||||
|
||||
impl Focusable for SidebarPanel {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SidebarPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let profile = backend
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.map(|public_key| self.directory.read(cx).profile(&public_key, cx));
|
||||
|
||||
if profile.is_none() {
|
||||
return self.render_sign_in(window, cx);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.when_some(profile.as_ref(), |this, profile| {
|
||||
this.child(self.render_user(profile, window, cx))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.child(
|
||||
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||
this.open_inbox(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
NavItem::new(
|
||||
"explore",
|
||||
"Browse",
|
||||
Icon::new(IconName::Globe).small(),
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
|this, _ev, window, cx| this.open_explore(window, cx),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
NavItem::new(
|
||||
"search",
|
||||
"Search",
|
||||
Icon::new(IconName::Search).small(),
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
|this, _ev, window, cx| this.open_explore(window, cx),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(self.render_repos(cx)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.child(
|
||||
NavItem::new("guide", "Guide", Icon::new(IconName::Info).small()).on_click(
|
||||
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
NavItem::new(
|
||||
"settings",
|
||||
"Settings",
|
||||
Icon::new(IconName::Settings).small(),
|
||||
)
|
||||
.on_click(cx.listener(|_, _ev, window, cx| {
|
||||
settings_dialog::open(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn open_exit_dialog(window: &mut Window, cx: &mut App) {
|
||||
window.open_alert_dialog(cx, |alert, _, _| {
|
||||
alert
|
||||
.title("Exit Signed?")
|
||||
.description(
|
||||
"You will be signed out on this device and will need to sign in again on the next session.",
|
||||
)
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("Exit")
|
||||
.ok_variant(ButtonVariant::Danger)
|
||||
.show_cancel(true),
|
||||
)
|
||||
.on_ok(|_, _, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.sign_out(cx);
|
||||
});
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, Window, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Progress of the onboarding flow, so async results can be rendered.
|
||||
pub type OnboardingState = DialogProgress;
|
||||
|
||||
pub fn open(
|
||||
name_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
repass_input: Entity<InputState>,
|
||||
state: Entity<OnboardingState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let name_input = name_input.clone();
|
||||
let pass_input = pass_input.clone();
|
||||
let repass_input = repass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Create identity"))
|
||||
.child(
|
||||
DialogDescription::new()
|
||||
.child("Set up your Signed identity to get started."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Name")
|
||||
.description("Max 255 characters")
|
||||
.required(true)
|
||||
.child(Input::new(&name_input)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Passphrase")
|
||||
.required(true)
|
||||
.child(Input::new(&pass_input)),
|
||||
)
|
||||
.child(field().required(true).child(Input::new(&repass_input))),
|
||||
)
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("continue")
|
||||
.primary()
|
||||
.label("Create new identity")
|
||||
.tooltip("Create identity")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let pass_input = pass_input.clone();
|
||||
let repass_input = repass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
move |_ev, window, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
let name = name_input.read(cx).value().to_string();
|
||||
let pass = pass_input.read(cx).value().to_string();
|
||||
let repass = repass_input.read(cx).value().to_string();
|
||||
|
||||
if pass != repass {
|
||||
state.update(cx, |state, _| {
|
||||
state.fail("Passphrases do not match");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_identity(&name, &pass, cx)
|
||||
});
|
||||
let handle = window.window_handle();
|
||||
let state = state.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(_) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.fail(e.to_string());
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyWindowHandle, App, Entity, Subscription, Window};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// State of the passphrase dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct PassphraseState {
|
||||
pub progress: DialogProgress,
|
||||
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
|
||||
_enter_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let pass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Passphrase to unlock your identity")
|
||||
.masked(true)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = cx.new(|_| PassphraseState::default());
|
||||
|
||||
// Enter in the passphrase field submits, like the Unlock button.
|
||||
let enter_pass_input = pass_input.clone();
|
||||
let enter_state = state.clone();
|
||||
let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| {
|
||||
if matches!(event, InputEvent::PressEnter { .. }) {
|
||||
unlock(&enter_pass_input, &enter_state, &handle, cx);
|
||||
}
|
||||
});
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state._enter_subscription = Some(enter_subscription)
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let pass_input = pass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
dialog
|
||||
.close_button(false)
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).progress.busy;
|
||||
let error = state.read(cx).progress.error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Unlock your identity"))
|
||||
.child(
|
||||
DialogDescription::new()
|
||||
.child("Enter the passphrase used to encrypt this identity."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_form().child(
|
||||
field()
|
||||
.label("Passphrase")
|
||||
.required(true)
|
||||
.child(Input::new(&pass_input)),
|
||||
),
|
||||
)
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("unlock")
|
||||
.primary()
|
||||
.icon(CustomIconName::Unlock)
|
||||
.label("Unlock")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let pass_input = pass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
move |_ev, _window, cx| {
|
||||
unlock(&pass_input, &state, &handle, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn unlock(
|
||||
pass_input: &Entity<InputState>,
|
||||
state: &Entity<PassphraseState>,
|
||||
handle: &AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let pass = pass_input.read(cx).value().to_string();
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| state.progress.begin());
|
||||
|
||||
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
||||
let handle = *handle;
|
||||
let state = state.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(_) => {
|
||||
cx.update_window(handle, |_this, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_this, _window, cx| {
|
||||
state.update(cx, |state, _| state.progress.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::select::{Select, SelectEvent, SelectState};
|
||||
use gpui_component::separator::Separator;
|
||||
use gpui_component::switch::Switch;
|
||||
use gpui_component::{
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
|
||||
use super::{normalize_server, server_host};
|
||||
|
||||
/// Looks up the option index used to seed a [`SelectState`].
|
||||
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
||||
options
|
||||
.iter()
|
||||
.position(|option| option.value().as_ref() == value)
|
||||
.map(|row| IndexPath::default().row(row))
|
||||
}
|
||||
|
||||
/// Created once when the dialog opens, so control state survives re-renders.
|
||||
struct SettingsControls {
|
||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||
grasp_server_input: Entity<InputState>,
|
||||
/// The effective create-repository folder, shown in a disabled input.
|
||||
default_folder: Entity<InputState>,
|
||||
/// Keeps the control subscriptions alive while the dialog is open.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SettingsControls {
|
||||
fn new(window: &mut Window, cx: &mut App) -> Self {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
|
||||
let appearance_options = vec![
|
||||
SelectOption::new("system", "System"),
|
||||
SelectOption::new("light", "Light"),
|
||||
SelectOption::new("dark", "Dark"),
|
||||
];
|
||||
let appearance_value = match settings.appearance {
|
||||
AppearanceMode::System => "system",
|
||||
AppearanceMode::Light => "light",
|
||||
AppearanceMode::Dark => "dark",
|
||||
};
|
||||
let appearance = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
appearance_options.clone(),
|
||||
selected_index(&appearance_options, appearance_value),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
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 grasp_server_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
||||
|
||||
let default_folder = cx.new(|cx| {
|
||||
let folder = settings
|
||||
.create_repository
|
||||
.default_folder
|
||||
.clone()
|
||||
.unwrap_or_else(paths::desktop_dir);
|
||||
InputState::new(window, cx).default_value(folder.to_string_lossy().to_string())
|
||||
});
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
subscriptions.push(cx.subscribe(&appearance, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let appearance = match value.as_ref() {
|
||||
"light" => AppearanceMode::Light,
|
||||
"dark" => AppearanceMode::Dark,
|
||||
_ => AppearanceMode::System,
|
||||
};
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.appearance = appearance, cx);
|
||||
});
|
||||
apply_appearance(appearance, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
appearance,
|
||||
event_fetching,
|
||||
grasp_server_input,
|
||||
default_folder,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let controls = Rc::new(SettingsControls::new(window, cx));
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let controls = controls.clone();
|
||||
dialog
|
||||
.title("Settings")
|
||||
.width(px(650.))
|
||||
.h(px(560.))
|
||||
.child(settings_view(&controls, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_4()
|
||||
.w_full()
|
||||
.child(appearance_section(controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(tab_bar_section(&settings, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(grasp_servers_section(&settings, controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(event_fetching_section(controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(repositories_section(&settings, controls, cx))
|
||||
}
|
||||
|
||||
fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
cx,
|
||||
"Appearance",
|
||||
"Choose whether the app follows the system theme or uses a light/dark theme.",
|
||||
Select::new(&controls.appearance).w_full(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
cx,
|
||||
"Hide Tab Navigation Buttons",
|
||||
"Enable to show navigation buttons when more than 10 tabs.",
|
||||
Switch::new("hide-tab-navigation")
|
||||
.checked(settings.tab_bar.hide_navigation_buttons)
|
||||
.on_click(move |checked: &bool, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| settings.tab_bar.hide_navigation_buttons = *checked,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
|
||||
fn grasp_servers_section(
|
||||
settings: &Settings,
|
||||
controls: &SettingsControls,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let servers = settings.grasp_servers.default_servers.clone();
|
||||
|
||||
v_flex().w_full().gap_3().child(setting_block(
|
||||
cx,
|
||||
"Grasp Servers",
|
||||
"Servers used to host your git repositories via the Grasp protocol",
|
||||
grasp_server_editor(&servers, controls, cx),
|
||||
))
|
||||
}
|
||||
|
||||
/// Styled like the grasp-server section of the publish dialogs.
|
||||
fn grasp_server_editor(
|
||||
servers: &[String],
|
||||
controls: &SettingsControls,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let server_input = controls.grasp_server_input.clone();
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.children(servers.iter().enumerate().map(|(ix, server)| {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(display_server(server)),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("settings-remove-server:{ix}"))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.flex_shrink_0()
|
||||
.tooltip("Remove")
|
||||
.on_click(move |_event, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.grasp_servers.default_servers.remove(ix);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(div().flex_1().child(Input::new(&server_input)))
|
||||
.child(
|
||||
Button::new("settings-add-server")
|
||||
.icon(IconName::Plus)
|
||||
.ghost()
|
||||
.tooltip("Add grasp server")
|
||||
.on_click({
|
||||
let server_input = server_input.clone();
|
||||
move |_event, window, cx| add_server(&server_input, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
||||
fn display_server(server: &str) -> SharedString {
|
||||
match normalize_server(server) {
|
||||
Some((_, relay)) => server_host(&relay),
|
||||
None => SharedString::from(server.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn repositories_section(
|
||||
settings: &Settings,
|
||||
controls: &SettingsControls,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let scan_paths = settings.local_repos.scan_paths.clone();
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(setting_block(
|
||||
cx,
|
||||
"Scan Directories",
|
||||
"Directories scanned for local git repositories.",
|
||||
scan_paths_editor(&scan_paths, cx),
|
||||
))
|
||||
.child(setting_block(
|
||||
cx,
|
||||
"Default Folder",
|
||||
"The folder for newly created repositories.",
|
||||
folder_selector(controls),
|
||||
))
|
||||
}
|
||||
|
||||
/// Styled like the grasp-server list.
|
||||
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.children(scan_paths.iter().enumerate().map(|(ix, path)| {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(path.display().to_string()),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("settings-remove-path:{ix}"))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.flex_shrink_0()
|
||||
.tooltip("Remove")
|
||||
.on_click(move |_event, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.local_repos.scan_paths.remove(ix);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}))
|
||||
.child(
|
||||
h_flex().justify_end().items_center().child(
|
||||
Button::new("settings-add-path")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add directory")
|
||||
.secondary()
|
||||
.small()
|
||||
.on_click(move |_event, _window, cx| add_scan_path(cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Matches the create-repository dialog.
|
||||
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
||||
let default_folder = controls.default_folder.clone();
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new(&default_folder).disabled(true)),
|
||||
)
|
||||
.child(
|
||||
Button::new("settings-choose-folder")
|
||||
.icon(IconName::Folder)
|
||||
.ghost()
|
||||
.tooltip("Choose folder")
|
||||
.on_click(move |_event, window, cx| {
|
||||
choose_default_folder(&default_folder, window, cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some((normalized, _)) = normalize_server(&value) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
if !settings.grasp_servers.default_servers.contains(&normalized) {
|
||||
settings
|
||||
.grasp_servers
|
||||
.default_servers
|
||||
.push(normalized.clone());
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
|
||||
fn add_scan_path(cx: &mut App) {
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: true,
|
||||
prompt: Some("Choose directories".into()),
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(paths))) = prompt.await {
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
for path in paths {
|
||||
let path = path.to_string_lossy().to_string();
|
||||
if !settings
|
||||
.local_repos
|
||||
.scan_paths
|
||||
.iter()
|
||||
.any(|existing| existing.to_string_lossy() == path)
|
||||
{
|
||||
settings.local_repos.scan_paths.push(path.into());
|
||||
}
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Persists the choice and reflects it in the disabled input.
|
||||
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let default_folder = default_folder.clone();
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Choose default folder".into()),
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = prompt.await
|
||||
&& let Some(path) = paths.pop()
|
||||
{
|
||||
let path = path.to_string_lossy().to_string();
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.create_repository.default_folder = Some(path.clone().into())
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
default_folder.update(cx, |input, cx| input.set_value(path, window, cx));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use nostr::prelude::Event;
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// Root events counted by their resolved status.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StatusCounts {
|
||||
pub(crate) total: usize,
|
||||
pub(crate) open: usize,
|
||||
pub(crate) closed: usize,
|
||||
pub(crate) draft: usize,
|
||||
pub(crate) applied: usize,
|
||||
}
|
||||
|
||||
impl StatusCounts {
|
||||
fn record(&mut self, status: RepoStatus) {
|
||||
self.total += 1;
|
||||
match status {
|
||||
RepoStatus::Open => self.open += 1,
|
||||
RepoStatus::Closed => self.closed += 1,
|
||||
RepoStatus::Draft => self.draft += 1,
|
||||
RepoStatus::Applied => self.applied += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of `roots` whose status `keep` accepts, counting every root's status.
|
||||
pub(crate) fn filter_by_status<'a>(
|
||||
roots: impl IntoIterator<Item = &'a Event>,
|
||||
status_of: impl Fn(&Event) -> RepoStatus,
|
||||
keep: impl Fn(RepoStatus) -> bool,
|
||||
) -> (Vec<usize>, StatusCounts) {
|
||||
let mut counts = StatusCounts::default();
|
||||
|
||||
let visible = roots
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, root)| {
|
||||
let status = status_of(root);
|
||||
counts.record(status);
|
||||
keep(status).then_some(index)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible, counts)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui_component::tree::TreeItem;
|
||||
|
||||
pub(crate) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
pub(crate) id: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
item = item.expanded(true);
|
||||
}
|
||||
item.children = seed
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect();
|
||||
item
|
||||
}
|
||||
|
||||
seeds
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// Sorted relative paths of a worktree snapshot.
|
||||
///
|
||||
/// Compared against the `worktree_paths` of a repository panel to skip
|
||||
/// rebuilding the explorer when a refresh left the worktree unchanged.
|
||||
pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
|
||||
let mut paths: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_nested_tree_from_flat_entries() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||
|
||||
assert_eq!(items[1].label, "README.md");
|
||||
assert_eq!(items[1].id, "README.md");
|
||||
|
||||
assert_eq!(items[2].label, "docs");
|
||||
assert_eq!(items[2].children[0].label, "guide.md");
|
||||
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_handles_deep_nesting() {
|
||||
let entries = vec![
|
||||
PathBuf::from("a"),
|
||||
PathBuf::from("a/b"),
|
||||
PathBuf::from("a/b/c.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].children[0].id, "a/b");
|
||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
PathBuf::from("a/z.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "a");
|
||||
assert_eq!(items[0].children.len(), 2);
|
||||
assert_eq!(items[1].label, "b");
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||
use gpui_component::notification::NotificationType;
|
||||
use gpui_component::{StyledExt, Theme, WindowExt};
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use crate::views::SidebarPanel;
|
||||
use crate::views::sidebar::passphrase_dialog;
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let settings = SettingsStore::global(cx);
|
||||
|
||||
let dock = cx.new(|cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
DockArea::new("dock", Some(1), window, cx).with_renderer(skin)
|
||||
});
|
||||
let weak_dock = dock.downgrade();
|
||||
|
||||
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
||||
let weak_sidebar = sidebar.downgrade();
|
||||
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
if settings.read(cx).settings().appearance == AppearanceMode::System {
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
}
|
||||
|
||||
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&dock,
|
||||
window,
|
||||
move |_, _, event: &DockEvent, window, cx| {
|
||||
if !matches!(event, DockEvent::LayoutChanged) {
|
||||
return;
|
||||
}
|
||||
let weak = weak_dock.clone();
|
||||
cx.spawn_in(window, async move |_, window| {
|
||||
weak.update_in(window, |area, window, cx| {
|
||||
for placement in [DockPlacement::Bottom, DockPlacement::Right] {
|
||||
if area.is_empty(placement, cx) {
|
||||
area.remove_dock(placement, window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
));
|
||||
|
||||
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&backend,
|
||||
window,
|
||||
|_this, _state, event, window, cx| {
|
||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
if let BackendEvent::Error(message) = event {
|
||||
window.push_notification((NotificationType::Error, message.clone()), cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
this.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
weak_sidebar
|
||||
.update(cx, |this, cx| {
|
||||
this.open_explore(window, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("workspace")
|
||||
.v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(self.dock.clone())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user