diff --git a/Cargo.lock b/Cargo.lock index 1639f46..73f9103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "cpal", "flume 0.12.0", @@ -3067,6 +3067,7 @@ dependencies = [ "gpui-native-icons", "gpui-native-theme", "gpui-native-utils", + "image", "log", "unicode-segmentation", ] @@ -3074,7 +3075,7 @@ dependencies = [ [[package]] name = "gpui-native-data" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "gpui", "gpui-native-core", @@ -3086,7 +3087,7 @@ dependencies = [ [[package]] name = "gpui-native-editor" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "gpui", "gpui-native-core", @@ -3105,7 +3106,7 @@ dependencies = [ [[package]] name = "gpui-native-icons" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "anyhow", "gpui", @@ -3114,7 +3115,7 @@ dependencies = [ [[package]] name = "gpui-native-kit" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "gpui", "gpui-native-core", @@ -3128,7 +3129,7 @@ dependencies = [ [[package]] name = "gpui-native-layout" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "gpui", "gpui-native-core", @@ -3141,7 +3142,7 @@ dependencies = [ [[package]] name = "gpui-native-theme" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "anyhow", "async-channel", @@ -3162,7 +3163,7 @@ dependencies = [ [[package]] name = "gpui-native-utils" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=c3e3c37731e389d858712369e47eec9c205cdfbe#c3e3c37731e389d858712369e47eec9c205cdfbe" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=bf8a92a133ac2066eb7b6fda7958d19233599923#bf8a92a133ac2066eb7b6fda7958d19233599923" dependencies = [ "gpui", "gpui-native-theme", diff --git a/Cargo.toml b/Cargo.toml index 623e1af..110e6d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,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 = "c3e3c37731e389d858712369e47eec9c205cdfbe", features = ["layout", "editor"] } +gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "bf8a92a133ac2066eb7b6fda7958d19233599923", features = ["layout", "editor"] } settings = { path = "crates/settings" } signed_app = { path = "crates/signed_app" } diff --git a/crates/signed_app/src/app/mod.rs b/crates/signed_app/src/app/mod.rs index 0515787..bc7a9ad 100644 --- a/crates/signed_app/src/app/mod.rs +++ b/crates/signed_app/src/app/mod.rs @@ -8,7 +8,7 @@ use gpui::{ }; use gpui_native_kit::common::h_flex; use gpui_native_kit::icons::IconRole; -use gpui_native_kit::layout::dock::{Dock, DockState}; +use gpui_native_kit::layout::dock::{Dock, DockSidebar, DockState}; use gpui_native_kit::layout::shell::{LayoutState, Shell, SidebarToggles}; use gpui_native_kit::layout::window_controls::{TRAFFIC_LIGHT_PAD, titlebar_strip}; use gpui_native_kit::theme::ActiveTheme as _; @@ -36,7 +36,7 @@ impl AppRoot { let layout = LayoutState::new(cx); layout.update(cx, |layout, cx| { - layout.set_sidebar_toggles(SidebarToggles::Hidden, cx); + layout.set_sidebar_toggles(SidebarToggles::Tabbar, cx); }); dock.update(cx, |dock, cx| { @@ -80,14 +80,13 @@ impl AppRoot { let Some(panel) = dock.read(cx).active_panel() else { return; }; - - if !panel.starts_with("repo:") && !panel.starts_with("repo-local:") { - return; - } + let is_repo = panel.starts_with("repo:") || panel.starts_with("repo-local:"); this.layout.update(cx, |layout, cx| { - if !layout.is_right_sidebar_visible() { + if is_repo && !layout.is_right_sidebar_visible() { layout.set_right_sidebar_visible(true, cx); + } else if !is_repo && layout.is_right_sidebar_visible() { + layout.set_right_sidebar_visible(false, cx); } }); })); @@ -144,7 +143,7 @@ impl Render for AppRoot { .child( Shell::new("signed-shell", self.layout.clone()) .left_sidebar(self.sidebar.read(cx).build(cx)) - .right_sidebar(div()) + .right_sidebar(DockSidebar::new("signed-dock-sidebar", self.dock.clone())) .center( Dock::new("signed-dock", self.dock.clone()) .window_controls(true) diff --git a/crates/signed_app/src/repo/files.rs b/crates/signed_app/src/repo/files.rs index a203b1c..3347455 100644 --- a/crates/signed_app/src/repo/files.rs +++ b/crates/signed_app/src/repo/files.rs @@ -1,10 +1,11 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; +use std::rc::Rc; use anyhow::Error; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Task, WeakEntity, Window, div, px, }; use gpui_native_kit::common::{h_flex, v_flex}; @@ -19,11 +20,12 @@ use super::helpers::{code_language, is_markdown_path, preview_spinner, source_ha use crate::shared::tree::{TreeItemSeed, TreeRow, collect_expanded, flatten}; use crate::shared::widgets::empty_state; -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; +pub(super) type ActivateHandler = Rc; + enum FileContent { Text(String), Binary, @@ -223,6 +225,14 @@ impl RepoFilesView { .unwrap_or_else(|| "Overview".into()) } + pub(super) fn tree_rows(&self) -> &[TreeRow] { + &self.rows + } + + pub(super) fn selected_index(&self) -> Option { + self.selected_index + } + fn activate_row(&mut self, index: usize, window: &mut Window, cx: &mut Context) { let Some(row) = self.rows.get(index) else { return; @@ -240,23 +250,19 @@ impl RepoFilesView { cx.notify(); } - fn render_tree_column( - &self, + pub(super) fn tree_element( rows: Vec, selected: Option, view: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { + on_activate: Option, + ) -> AnyElement { let change_view = view.clone(); let disclosure_view = view; + let rows_for_change = rows.clone(); v_flex() - .h_full() - .w(px(TREE_WIDTH)) - .p_2() - .flex_none() - .border_r_1() - .border_color(cx.theme().colors.border) + .size_full() + .p_1() .child( div().flex_1().min_h_0().child( Tree::new("repo-tree") @@ -287,6 +293,14 @@ impl RepoFilesView { { log::warn!("failed to activate tree row: {error}"); } + + let is_file = index + .and_then(|index| rows_for_change.get(index)) + .is_some_and(|row| !row.expandable); + + if is_file && let Some(on_activate) = &on_activate { + on_activate(window, cx); + } } }) .on_disclosure({ @@ -305,6 +319,7 @@ impl RepoFilesView { }), ), ) + .into_any_element() } fn render_content_column( @@ -712,16 +727,12 @@ impl RepoFilesView { impl Render for RepoFilesView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let rows = self.rows.clone(); - let selected = self.selected_index; - let view = cx.entity().downgrade(); let pane_title = self.pane_title(); h_flex() .flex_1() .w_full() .overflow_hidden() - .child(self.render_tree_column(rows, selected, view, cx)) .child(self.render_content_column(pane_title, cx)) } } diff --git a/crates/signed_app/src/repo/header.rs b/crates/signed_app/src/repo/header.rs index 9568fb8..74f11e1 100644 --- a/crates/signed_app/src/repo/header.rs +++ b/crates/signed_app/src/repo/header.rs @@ -4,17 +4,15 @@ use assets::CustomIconName; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, AppContext as _, ClipboardItem, Context, IntoElement, ParentElement as _, - SharedString, Styled as _, Window, div, px, + SharedString, Styled as _, Window, div, }; use gpui_native_kit::avatar::Avatar; use gpui_native_kit::button::{Button, ButtonVariant}; -use gpui_native_kit::clipboard::ClipboardButton; use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; use gpui_native_kit::data::tabs::{Tab, Tabs}; use gpui_native_kit::dropdown_menu::DropdownMenu; use gpui_native_kit::icons::{Icon, IconRole}; use gpui_native_kit::menu::MenuItem; -use gpui_native_kit::popover::Popover; use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _}; use gpui_native_kit::tooltip::Tooltip; use signed_state::{Backend, Nip34Kind}; @@ -22,9 +20,7 @@ use signed_state::{Backend, Nip34Kind}; use super::RepoDetailView; use super::actions; use super::dialogs; -use super::helpers::{ - ShareTargets, bound_repo_label, clone_group, fork_row, nostr_clone_url, repo_display_name, -}; +use super::helpers::{ShareTargets, bound_repo_label, fork_row, repo_display_name}; use crate::issues::IssuesView; use crate::pull_requests::{NewPullRequestView, PullRequestsView, SendPatchView}; @@ -49,7 +45,7 @@ impl RepoDetailView { dialogs::init::open(local_path, cx.entity().downgrade(), window, cx); } - fn open_pulls(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn open_pulls(&mut self, window: &mut Window, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; }; @@ -99,7 +95,7 @@ impl RepoDetailView { ); } - fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; }; @@ -128,7 +124,7 @@ impl RepoDetailView { ); } - fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; }; @@ -153,27 +149,12 @@ impl RepoDetailView { let store = self.store.read(cx); let pushing = store.pushing; - let cloning = store.cloning; let Some(announcement) = store.announcement.clone() else { return div().into_any_element(); }; - let nip05 = self - .directory - .read(cx) - .profile(&announcement.owner, cx) - .metadata() - .nip05 - .clone() - .filter(|nip05| !nip05.trim().is_empty()); - let share = ShareTargets::from_announcement(&announcement); - let nostr_url = nostr_clone_url(&announcement, nip05.as_deref()); - let ngit_command = SharedString::from(format!("git clone {nostr_url}")); - let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); - let git_commands: Vec = announcement.clone_urls(); - let name = repo_display_name(self.store.read(cx)); let description = announcement.description(); let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)); @@ -184,11 +165,6 @@ impl RepoDetailView { let weak_share = cx.entity().downgrade(); let weak_menu_open = cx.entity().downgrade(); let weak_menu_items = cx.entity().downgrade(); - let weak_clone = cx.entity().downgrade(); - let weak_download = cx.entity().downgrade(); - let ngit_for_popover = ngit_command.clone(); - let nak_for_popover = nak_command.clone(); - let git_for_popover = git_commands.clone(); v_flex() .flex_none() @@ -243,90 +219,6 @@ impl RepoDetailView { .flex_none() .gap_2() .justify_end() - .child({ - let weak_pulls = cx.entity().downgrade(); - let weak_pulls_open = cx.entity().downgrade(); - let _store_for_pulls = self.store.clone(); - let pulls_count = self.store.read(cx).pull_request_count(); - - DropdownMenu::new("pulls") - .label(format!("Pull requests {pulls_count}")) - .icon(Icon::named(CustomIconName::GitPullRequest)) - .variant(ButtonVariant::Secondary) - .open(self.pulls_menu_open) - .on_click(move |_, window, cx| { - if let Err(error) = weak_pulls.update(cx, |this, cx| { - this.open_pulls(window, cx); - }) { - log::warn!("failed to open pull requests: {error}"); - } - }) - .on_open_change(move |open, _, cx| { - if let Err(error) = weak_pulls_open.update(cx, |this, cx| { - this.pulls_menu_open = open; - cx.notify(); - }) { - log::warn!("failed to update pulls menu: {error}"); - } - }) - .items({ - let weak_new = cx.entity().downgrade(); - let weak_patch = cx.entity().downgrade(); - move |_, _| { - let weak_new = weak_new.clone(); - let weak_patch = weak_patch.clone(); - vec![ - MenuItem::new("New pull request").handler(move |window, cx| { - crate::shared::log_dropped(weak_new.update(cx, |this, cx| { - this.open_new_pull_request(window, cx); - })); - }), - MenuItem::new("Send patch").handler(move |window, cx| { - crate::shared::log_dropped(weak_patch.update(cx, |this, cx| { - this.open_send_patch(window, cx); - })); - }), - ] - } - }) - }) - .child({ - let weak_issues = cx.entity().downgrade(); - let weak_issues_open = cx.entity().downgrade(); - let store_for_issues = self.store.clone(); - let issues_count = self.store.read(cx).issue_count(); - - DropdownMenu::new("issues") - .label(format!("Issues {issues_count}")) - .icon(Icon::named(CustomIconName::GitIssueDone)) - .variant(ButtonVariant::Secondary) - .open(self.issues_menu_open) - .on_click(move |_, window, cx| { - if let Err(error) = weak_issues.update(cx, |this, cx| { - this.open_issues(window, cx); - }) { - log::warn!("failed to open issues: {error}"); - } - }) - .on_open_change(move |open, _, cx| { - if let Err(error) = - weak_issues_open.update(cx, |this, cx| { - this.issues_menu_open = open; - cx.notify(); - }) - { - log::warn!("failed to update issues menu: {error}"); - } - }) - .items(move |_, _| { - vec![MenuItem::new("New issue").handler({ - let store = store_for_issues.clone(); - move |_, cx| { - crate::issues::new_issue::open(store.clone(), cx); - } - })] - }) - }) .child( DropdownMenu::new("share") .icon(Icon::named(IconRole::Copy)) @@ -429,95 +321,6 @@ impl RepoDetailView { items }), ) - .child( - Popover::new("clone") - .open(self.clone_open) - .on_change(move |open, _, cx| { - if let Err(error) = weak_clone.update(cx, |this, cx| { - this.clone_open = open; - cx.notify(); - }) { - log::warn!("failed to update clone popover: {error}"); - } - }) - .trigger(move |_, _| { - Button::new("clone") - .icon(Icon::named(CustomIconName::GitClone)) - .tooltip(Tooltip::text("Clone")) - .loading(cloning) - .disabled(cloning) - .into_any_element() - }) - .content(move |_, cx| { - v_flex() - .w(px(440.)) - .p_3() - .gap_4() - .child(clone_group( - "Clone with ngit", - "copy-ngit", - &ngit_for_popover, - cx, - )) - .child(clone_group( - "Clone with nak", - "copy-nak", - &nak_for_popover, - cx, - )) - .child( - v_flex() - .gap_1() - .child( - div() - .typeset(TextRole::Caption, cx) - .text_color( - cx.theme().colors.muted_foreground, - ) - .child("Grasp Servers"), - ) - .when(git_for_popover.is_empty(), |this| { - this.child( - div() - .typeset(TextRole::Caption, cx) - .child("No git clone urls."), - ) - }) - .children(git_for_popover.iter().enumerate().map( - |(ix, command)| { - ClipboardButton::new( - SharedString::from(format!( - "copy-git-{ix}" - )), - command.clone(), - ) - }, - )), - ) - .child( - h_flex().justify_end().child( - Button::new("download") - .icon(Icon::named(CustomIconName::GitClone)) - .label("Download") - .on_click({ - let weak = weak_download.clone(); - move |_, window, cx| { - if let Err(error) = - weak.update(cx, |this, cx| { - this.clone_to_folder(window, cx); - }) - { - log::warn!( - "failed to clone: {error}" - ); - } - } - }), - ), - ) - .into_any_element() - }), - ), ), ) .child(self.render_tabs(cx)) diff --git a/crates/signed_app/src/repo/mod.rs b/crates/signed_app/src/repo/mod.rs index 7cfa0b0..155ceb5 100644 --- a/crates/signed_app/src/repo/mod.rs +++ b/crates/signed_app/src/repo/mod.rs @@ -5,9 +5,9 @@ use anyhow::Error; use assets::CustomIconName; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, - ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, Subscription, Task, - WeakEntity, Window, div, + AnyElement, AnyView, App, AppContext as _, Context, Entity, InteractiveElement as _, + IntoElement, ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, + Subscription, Task, WeakEntity, Window, div, }; use gpui_native_kit::alert::{Alert, AlertSeverity}; use gpui_native_kit::badge::Badge; @@ -35,12 +35,14 @@ mod header; pub mod helpers; pub mod history; pub mod refs; +mod sidebar; use banners::Banners; use files::RepoFilesView; use helpers::{RepoData, load_repo_data, repo_display_name}; use history::RepoHistoryView; use refs::RefSwitcher; +use sidebar::RepoRightSidebar; use crate::shared::tree::{build_tree_items, sorted_worktree_paths}; use crate::shared::widgets::repo_tab_avatar; @@ -76,11 +78,10 @@ pub struct RepoDetailView { pub(super) directory: Entity, /// Sync status feeding the sync-to-Nostr banner. sync_directory: Entity, + /// Actions and file tree shown in the shell's right sidebar. + right_sidebar: Entity, pub(super) share_open: bool, pub(super) repo_menu_open: bool, - pub(super) clone_open: bool, - pub(super) issues_menu_open: bool, - pub(super) pulls_menu_open: bool, _subscriptions: Vec, } @@ -131,6 +132,19 @@ impl RepoDetailView { let history = cx.new(|_| RepoHistoryView::new(store.clone(), dock.clone())); let weak = cx.entity().downgrade(); let refs = RefSwitcher::new(weak.clone(), window, cx); + let directory = ProfileDirectory::new(cx); + let sync_directory = SyncStatusDirectory::new(cx); + + let right_sidebar = cx.new(|cx| { + RepoRightSidebar::new( + weak.clone(), + store.clone(), + files.clone(), + directory.clone(), + sync_directory.clone(), + cx, + ) + }); let subscriptions = vec![cx.observe(&checkouts, |this, _checkouts, cx| { if this.refresh_statuses(cx) { @@ -160,13 +174,11 @@ impl RepoDetailView { refs, tasks: Vec::new(), banners: Banners::default(), - directory: ProfileDirectory::new(cx), - sync_directory: SyncStatusDirectory::new(cx), + directory, + sync_directory, + right_sidebar, share_open: false, repo_menu_open: false, - clone_open: false, - issues_menu_open: false, - pulls_menu_open: false, _subscriptions: subscriptions, } } @@ -487,7 +499,7 @@ impl RepoDetailView { self.refs.set_tags(tags, window, cx); } - fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { let store = self.store.clone(); let name = { @@ -903,7 +915,7 @@ impl RepoDetailView { .find(|status| !self.banners.dismissal(status)) } - fn sync_suggestion(&self, cx: &App) -> Option { + pub(super) fn sync_candidate(&self, cx: &App) -> Option { let store = self.store.read(cx); let addr = store.addr()?; let user = Backend::global(cx).read(cx).current_user()?; @@ -912,11 +924,24 @@ impl RepoDetailView { return None; } - let statuses = self.sync_directory.read(cx).statuses_of(addr, cx); + self.sync_directory + .read(cx) + .statuses_of(addr, cx) + .into_iter() + .find(|checkout| checkout.status.ahead_total > 0) + } - statuses.into_iter().find(|checkout| { - checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout) - }) + fn sync_suggestion(&self, cx: &App) -> Option { + self.sync_candidate(cx) + .filter(|checkout| !self.banners.sync_dismissal(checkout)) + } + + pub(super) fn sync_now(&mut self, window: &mut Window, cx: &mut Context) { + let Some(checkout) = self.sync_candidate(cx) else { + return; + }; + let path = checkout.path.clone(); + self.push_unpushed_checkout(path, window, cx); } fn render_push_banner(&self, cx: &Context) -> Option { @@ -977,7 +1002,6 @@ impl RepoDetailView { fn render_sync_banner(&self, cx: &Context) -> Option { let checkout = self.sync_suggestion(cx)?; - let path = checkout.path.clone(); let pushing = self.store.read(cx).pushing; let (branch, ahead) = checkout @@ -1015,30 +1039,17 @@ impl RepoDetailView { .child("ahead of Nostr"), ) .child( - h_flex() - .gap_1() - .child( - Button::new("sync-banner") - .icon(Icon::named(IconRole::Refresh)) - .label("Sync now") - .small() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(move |this, _, window, cx| { - this.push_unpushed_checkout(path.clone(), window, cx); - })), - ) - .child( - Button::new("dismiss-sync-banner") - .icon(Icon::named(IconRole::Close)) - .variant(ButtonVariant::Ghost) - .small() - .disabled(pushing) - .on_click(cx.listener(move |this, _, _, cx| { - this.banners.sync_dismiss(&checkout); - cx.notify(); - })), - ), + h_flex().gap_1().child( + Button::new("dismiss-sync-banner") + .icon(Icon::named(IconRole::Close)) + .variant(ButtonVariant::Ghost) + .small() + .disabled(pushing) + .on_click(cx.listener(move |this, _, _, cx| { + this.banners.sync_dismiss(&checkout); + cx.notify(); + })), + ), ) .into_any_element(), ) @@ -1193,6 +1204,10 @@ impl Panel for RepoDetailView { fn lead(&self, cx: &App) -> Option { Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) } + + fn right_sidebar(&mut self, _window: &mut Window, _cx: &mut App) -> Option { + Some(self.right_sidebar.clone().into()) + } } impl Render for RepoDetailView { diff --git a/crates/signed_app/src/repo/sidebar.rs b/crates/signed_app/src/repo/sidebar.rs new file mode 100644 index 0000000..7a371bf --- /dev/null +++ b/crates/signed_app/src/repo/sidebar.rs @@ -0,0 +1,338 @@ +use std::rc::Rc; + +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, Subscription, WeakEntity, Window, div, px, +}; +use gpui_native_kit::button::{Button, ButtonSide, ButtonVariant}; +use gpui_native_kit::clipboard::ClipboardButton; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::dropdown_menu::DropdownMenu; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::menu::MenuItem; +use gpui_native_kit::popover::Popover; +use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _}; +use gpui_native_kit::tooltip::Tooltip; +use metadata::ProfileDirectory; +use signed_state::{Backend, RepoStore, SyncStatusDirectory}; + +use super::RepoDetailView; +use super::files::{ActivateHandler, RepoFilesView}; +use super::helpers::{clone_group, nostr_clone_url}; +use crate::shared::widgets::empty_state; + +pub(super) struct RepoRightSidebar { + repo: WeakEntity, + store: Entity, + files: Entity, + directory: Entity, + sync_directory: Entity, + issues_menu_open: bool, + pulls_menu_open: bool, + clone_open: bool, + _subscriptions: Vec, +} + +impl RepoRightSidebar { + pub(super) fn new( + repo: WeakEntity, + store: Entity, + files: Entity, + directory: Entity, + sync_directory: Entity, + cx: &mut Context, + ) -> Self { + // The dock caches the right view, so observe its sources instead of relying on render reads. + let subscriptions = vec![ + cx.observe(&files, |_, _, cx| cx.notify()), + cx.observe(&store, |_, _, cx| cx.notify()), + cx.observe(&sync_directory, |_, _, cx| cx.notify()), + cx.observe(&directory, |_, _, cx| cx.notify()), + ]; + + Self { + repo, + store, + files, + directory, + sync_directory, + issues_menu_open: false, + pulls_menu_open: false, + clone_open: false, + _subscriptions: subscriptions, + } + } + + fn render_issues(&self, count: usize, cx: &mut Context) -> AnyElement { + let repo = self.repo.clone(); + let sidebar = cx.entity().downgrade(); + + DropdownMenu::new("sidebar-issues") + .label(format!("Issues {count}")) + .icon(Icon::named(CustomIconName::GitIssueDone)) + .variant(ButtonVariant::Secondary) + .small() + .open(self.issues_menu_open) + .on_click(move |_, window, cx| { + if let Err(error) = repo.update(cx, |repo, cx| repo.open_issues(window, cx)) { + log::warn!("failed to open issues: {error}"); + } + }) + .on_open_change(move |open, _, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.issues_menu_open = open; + cx.notify(); + }) { + log::warn!("failed to update issues menu: {error}"); + } + }) + .items({ + let store = self.store.clone(); + move |_, _| { + let store = store.clone(); + vec![MenuItem::new("New issue").handler(move |_, cx| { + crate::issues::new_issue::open(store.clone(), cx); + })] + } + }) + .into_any_element() + } + + fn render_pulls(&self, count: usize, cx: &mut Context) -> AnyElement { + let repo = self.repo.clone(); + let sidebar = cx.entity().downgrade(); + + DropdownMenu::new("sidebar-pulls") + .label(format!("Pull requests {count}")) + .icon(Icon::named(CustomIconName::GitPullRequest)) + .variant(ButtonVariant::Secondary) + .small() + .open(self.pulls_menu_open) + .on_click(move |_, window, cx| { + if let Err(error) = repo.update(cx, |repo, cx| repo.open_pulls(window, cx)) { + log::warn!("failed to open pull requests: {error}"); + } + }) + .on_open_change(move |open, _, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.pulls_menu_open = open; + cx.notify(); + }) { + log::warn!("failed to update pulls menu: {error}"); + } + }) + .items({ + let repo = self.repo.clone(); + move |_, _| { + let new_pr = repo.clone(); + let send_patch = repo.clone(); + vec![ + MenuItem::new("New pull request").handler(move |window, cx| { + if let Err(error) = new_pr.update(cx, |repo, cx| { + repo.open_new_pull_request(window, cx); + }) { + log::warn!("failed to open new pull request: {error}"); + } + }), + MenuItem::new("Send patch").handler(move |window, cx| { + if let Err(error) = + send_patch.update(cx, |repo, cx| repo.open_send_patch(window, cx)) + { + log::warn!("failed to open send patch: {error}"); + } + }), + ] + } + }) + .into_any_element() + } + + fn render_sync(&self, available: bool, pushing: bool, _cx: &mut Context) -> AnyElement { + let repo = self.repo.clone(); + + Button::new("sidebar-sync") + .icon(IconRole::Refresh) + .label("Sync") + .side(ButtonSide::Left) + .variant(ButtonVariant::Secondary) + .small() + .loading(pushing) + .disabled(pushing || !available) + .tooltip(if available { + Tooltip::text("Push local commits to Nostr") + } else { + Tooltip::text("No local commits to sync") + }) + .on_click(move |_, window, cx| { + if let Err(error) = repo.update(cx, |repo, cx| repo.sync_now(window, cx)) { + log::warn!("failed to sync repository: {error}"); + } + }) + .into_any_element() + } + + fn render_clone(&self, cloning: bool, cx: &mut Context) -> AnyElement { + let Some(announcement) = self.store.read(cx).announcement.clone() else { + return div().into_any_element(); + }; + + let nip05 = self + .directory + .read(cx) + .profile(&announcement.owner, cx) + .metadata() + .nip05 + .clone() + .filter(|nip05| !nip05.trim().is_empty()); + let nostr_url = nostr_clone_url(&announcement, nip05.as_deref()); + let ngit_command = SharedString::from(format!("git clone {nostr_url}")); + let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); + let git_commands: Vec = announcement.clone_urls(); + let sidebar = cx.entity().downgrade(); + let repo = self.repo.clone(); + + Popover::new("sidebar-clone") + .open(self.clone_open) + .on_change(move |open, _, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.clone_open = open; + cx.notify(); + }) { + log::warn!("failed to update clone popover: {error}"); + } + }) + .trigger(move |_, _| { + Button::new("sidebar-clone-trigger") + .icon(Icon::named(CustomIconName::GitClone)) + .label("Clone") + .side(ButtonSide::Left) + .variant(ButtonVariant::Secondary) + .small() + .loading(cloning) + .disabled(cloning) + .into_any_element() + }) + .content(move |_, cx| { + let repo = repo.clone(); + v_flex() + .w(px(440.)) + .p_3() + .gap_4() + .child(clone_group( + "Clone with ngit", + "copy-ngit", + &ngit_command, + cx, + )) + .child(clone_group("Clone with nak", "copy-nak", &nak_command, cx)) + .child( + v_flex() + .gap_1() + .child( + div() + .typeset(TextRole::Caption, cx) + .text_color(cx.theme().colors.muted_foreground) + .child("Grasp Servers"), + ) + .when(git_commands.is_empty(), |this| { + this.child( + div() + .typeset(TextRole::Caption, cx) + .child("No git clone urls."), + ) + }) + .children(git_commands.iter().enumerate().map(|(ix, command)| { + ClipboardButton::new( + SharedString::from(format!("copy-sidebar-git-{ix}")), + command.clone(), + ) + })), + ) + .child( + h_flex().justify_end().child( + Button::new("sidebar-clone-download") + .icon(Icon::named(CustomIconName::GitClone)) + .label("Download") + .on_click(move |_, window, cx| { + if let Err(error) = + repo.update(cx, |repo, cx| repo.clone_to_folder(window, cx)) + { + log::warn!("failed to clone: {error}"); + } + }), + ), + ) + .into_any_element() + }) + .into_any_element() + } +} + +impl Render for RepoRightSidebar { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let store = self.store.read(cx); + let addr = store.addr().cloned(); + let pushing = store.pushing; + let cloning = store.cloning; + let issues_count = store.issue_count(); + let pulls_count = store.pull_request_count(); + let rows = self.files.read(cx).tree_rows().to_vec(); + let selected = self.files.read(cx).selected_index(); + let files = self.files.downgrade(); + let repo = self.repo.clone(); + let on_activate: ActivateHandler = Rc::new(move |_, cx| { + if let Err(error) = repo.update(cx, |repo, cx| { + if repo.active_tab != 0 { + repo.active_tab = 0; + cx.notify(); + } + }) { + log::warn!("failed to switch to files: {error}"); + } + }); + + let sync_available = addr.as_ref().is_some_and(|addr| { + let user = Backend::global(cx).read(cx).current_user(); + user.is_some_and(|user| store.is_author(&user)) + && self + .sync_directory + .read(cx) + .statuses_of(addr, cx) + .iter() + .any(|status| status.status.ahead_total > 0) + }); + + let actions = addr.as_ref().map(|_| { + v_flex() + .w_full() + .gap_1() + .child(self.render_issues(issues_count, cx)) + .child(self.render_pulls(pulls_count, cx)) + .child(self.render_sync(sync_available, pushing, cx)) + .child(self.render_clone(cloning, cx)) + .into_any_element() + }); + + v_flex() + .size_full() + .p_2() + .gap_2() + .when_some(actions, |this, actions| this.child(actions)) + .child( + div() + .w_full() + .px_1() + .typeset(TextRole::Headline, cx) + .text_color(cx.theme().colors.muted_foreground) + .child("Files"), + ) + .child(div().w_full().h(px(1.)).bg(cx.theme().colors.border)) + .child(div().flex_1().min_h_0().child(if rows.is_empty() { + empty_state("No files") + } else { + RepoFilesView::tree_element(rows, selected, files, Some(on_activate)) + })) + } +}