diff --git a/crates/signed_app/src/identity/onboarding.rs b/crates/signed_app/src/identity/onboarding.rs index 236c3fa..dcd7007 100644 --- a/crates/signed_app/src/identity/onboarding.rs +++ b/crates/signed_app/src/identity/onboarding.rs @@ -128,7 +128,9 @@ impl Render for OnboardingDialog { .loading(busy) .disabled(busy) .on_click(move |_, _, cx| { - let _ = this.update(cx, |this, cx| this.create_identity(cx)); + crate::shared::log_dropped( + this.update(cx, |this, cx| this.create_identity(cx)), + ); }) .into_any_element(); diff --git a/crates/signed_app/src/identity/passphrase.rs b/crates/signed_app/src/identity/passphrase.rs index 258a207..a75ad33 100644 --- a/crates/signed_app/src/identity/passphrase.rs +++ b/crates/signed_app/src/identity/passphrase.rs @@ -123,7 +123,7 @@ impl Render for PassphraseDialog { .loading(busy) .disabled(busy) .on_click(move |_, _, cx| { - let _ = this.update(cx, |this, cx| this.unlock(cx)); + crate::shared::log_dropped(this.update(cx, |this, cx| this.unlock(cx))); }) .into_any_element(); diff --git a/crates/signed_app/src/inbox/helpers.rs b/crates/signed_app/src/inbox/helpers.rs index e2a8cc2..7881ca4 100644 --- a/crates/signed_app/src/inbox/helpers.rs +++ b/crates/signed_app/src/inbox/helpers.rs @@ -13,7 +13,7 @@ use metadata::ProfileDirectory; use nostr::prelude::{Event, Kind, PublicKey}; use utils::relative_time; -pub fn repo_header(name: SharedString, unread: usize) -> AnyElement { +pub fn repo_header(id: SharedString, name: SharedString, unread: usize) -> AnyElement { h_flex() .h_12() .w_full() @@ -27,7 +27,7 @@ pub fn repo_header(name: SharedString, unread: usize) -> AnyElement { .child(name), ) .when(unread > 0, |this| { - this.child(Badge::new("inbox-unread", unread.to_string())) + this.child(Badge::new(id.clone(), unread.to_string())) }) .into_any_element() } diff --git a/crates/signed_app/src/inbox/mod.rs b/crates/signed_app/src/inbox/mod.rs index 5542f2d..28494b5 100644 --- a/crates/signed_app/src/inbox/mod.rs +++ b/crates/signed_app/src/inbox/mod.rs @@ -27,13 +27,6 @@ use crate::repo::actions::{RepoItem, open_repo_item}; const MAX_SUB_ACTIVITIES: usize = 5; -struct InboxSection { - address: Option, - unread: usize, - entries: Vec, - latest: Timestamp, -} - #[derive(Clone, Copy)] enum InboxRow { Repo(usize), @@ -41,12 +34,18 @@ enum InboxRow { Empty, } +struct InboxSection { + address: Option, + unread: usize, + entries: Vec, + latest: Timestamp, +} + pub struct InboxView { dock: WeakEntity, threads: Arc>, sections: Arc>, rows: Arc>, - unread_count: usize, state: InboxReadState, state_loaded: bool, refresh: RefreshGate, @@ -89,7 +88,6 @@ impl InboxView { 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(), @@ -182,7 +180,7 @@ impl InboxView { 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 { + let (threads, _unread_count) = match work.await { Ok(results) => results, Err(error) => { log::warn!("inbox refresh failed: {error}"); @@ -196,7 +194,6 @@ impl InboxView { return false; } this.threads = Arc::new(threads); - this.unread_count = unread_count; this.rebuild(cx); cx.notify(); this.refresh.finish() @@ -214,7 +211,6 @@ impl InboxView { 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); } @@ -310,7 +306,6 @@ impl InboxView { 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; self.refresh = RefreshGate::default(); @@ -348,7 +343,15 @@ impl InboxView { return div().into_any_element(); }; let name = repo_name(section.address.as_ref(), cx); - repo_header(name, section.unread) + let id = SharedString::from(format!( + "inbox-unread:{}", + section + .address + .as_ref() + .map(ToString::to_string) + .unwrap_or_default() + )); + repo_header(id, name, section.unread) } InboxRow::Entry(section_ix, entry_ix) => { let Some(section) = self.sections.get(section_ix) else { @@ -389,19 +392,6 @@ impl InboxView { } } -fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> SharedString { - let Some(addr) = addr else { - return SharedString::from("Untitled"); - }; - RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|announcement| announcement.addr() == *addr) - .and_then(|announcement| announcement.name.as_deref().map(SharedString::from)) - .unwrap_or_else(|| SharedString::from("Untitled")) -} - impl Panel for InboxView { fn title(&self, _cx: &App) -> Option { Some(SharedString::from("Inbox")) @@ -465,3 +455,22 @@ impl Render for InboxView { .into_any_element() } } + +fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> SharedString { + let Some(addr) = addr else { + return SharedString::from("Untitled"); + }; + RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == *addr) + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_else(|| SharedString::from("Untitled")) +} diff --git a/crates/signed_app/src/issues/detail.rs b/crates/signed_app/src/issues/detail.rs index 402c707..a543366 100644 --- a/crates/signed_app/src/issues/detail.rs +++ b/crates/signed_app/src/issues/detail.rs @@ -40,7 +40,7 @@ impl IssueDetailView { let directory = ProfileDirectory::new(cx); let weak = cx.entity().downgrade(); cx.defer(move |cx| { - let _ = weak.update(cx, |this, cx| this.track_participants(cx)); + crate::shared::log_dropped(weak.update(cx, |this, cx| this.track_participants(cx))); }); Self { diff --git a/crates/signed_app/src/issues/mod.rs b/crates/signed_app/src/issues/mod.rs index 560f0a6..c142a56 100644 --- a/crates/signed_app/src/issues/mod.rs +++ b/crates/signed_app/src/issues/mod.rs @@ -60,7 +60,7 @@ impl IssuesView { }); let weak = cx.entity().downgrade(); cx.defer(move |cx| { - let _ = weak.update(cx, |this, cx| this.rebuild(cx)); + crate::shared::log_dropped(weak.update(cx, |this, cx| this.rebuild(cx))); }); Self { diff --git a/crates/signed_app/src/issues/new_issue.rs b/crates/signed_app/src/issues/new_issue.rs index d88d274..9eda34c 100644 --- a/crates/signed_app/src/issues/new_issue.rs +++ b/crates/signed_app/src/issues/new_issue.rs @@ -83,7 +83,7 @@ impl Render for NewIssueDialog { Button::new("create-issue") .label("Create issue") .on_click(move |_, _, cx| { - let _ = this.update(cx, |this, cx| this.submit(cx)); + crate::shared::log_dropped(this.update(cx, |this, cx| this.submit(cx))); }) .into_any_element() as AnyElement, ] diff --git a/crates/signed_app/src/pull_requests/detail.rs b/crates/signed_app/src/pull_requests/detail.rs index 901d95c..5e9de10 100644 --- a/crates/signed_app/src/pull_requests/detail.rs +++ b/crates/signed_app/src/pull_requests/detail.rs @@ -85,7 +85,7 @@ impl PullRequestDetailView { }); cx.defer(move |cx| { - let _ = weak.update(cx, |this, cx| this.sync(cx)); + crate::shared::log_dropped(weak.update(cx, |this, cx| this.sync(cx))); }); Self { @@ -291,7 +291,6 @@ impl PullRequestDetailView { None => SharedString::from(format!("commit:{}:{commit_id}", worktree.display())), }; let dock = self.dock.clone(); - let dock_for_build = dock.clone(); let store = self.store.clone(); let repo_name = self.repo_name.clone(); let commit_id = commit_id.to_owned(); @@ -313,7 +312,6 @@ impl PullRequestDetailView { window, cx, ); - let _ = dock_for_build; } fn render_tabs(&self, cx: &mut Context) -> AnyElement { @@ -339,10 +337,10 @@ impl PullRequestDetailView { ] }) .on_change(move |index, _, cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { this.active_tab = index; cx.notify(); - }); + })); }) .into_any_element() } @@ -476,9 +474,9 @@ impl PullRequestDetailView { &commit, move |window, cx| { if let Some(worktree) = worktree.clone() { - let _ = view.update(cx, |this, cx| { + crate::shared::log_dropped(view.update(cx, |this, cx| { this.open_commit_diff(worktree, &id, window, cx); - }); + })); } }, cx, @@ -555,7 +553,7 @@ impl PullRequestDetailView { .when(can_update, |this| { this.child(Button::new("update-pr").label("Update").small().on_click( move |_, _, cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { let root = this .store .read(cx) @@ -568,7 +566,7 @@ impl PullRequestDetailView { if let Some(root) = root { dialogs::update::open(this.store.clone(), root, cx); } - }); + })); }, )) }) diff --git a/crates/signed_app/src/pull_requests/dialogs/update.rs b/crates/signed_app/src/pull_requests/dialogs/update.rs index a93a97a..36eb99a 100644 --- a/crates/signed_app/src/pull_requests/dialogs/update.rs +++ b/crates/signed_app/src/pull_requests/dialogs/update.rs @@ -13,10 +13,6 @@ use signed_state::RepoStore; use crate::shared::dialogs::{close_global, open_global}; -pub fn open(store: Entity, root: Event, cx: &mut App) { - open_global(UpdatePrDialog::new(store, root, cx), cx); -} - pub struct UpdatePrDialog { store: Entity, root: Event, @@ -76,10 +72,14 @@ impl Render for UpdatePrDialog { Button::new("submit-update") .label("Update pull request") .on_click(move |_, _, cx| { - let _ = this.update(cx, |this, cx| this.submit(cx)); + crate::shared::log_dropped(this.update(cx, |this, cx| this.submit(cx))); }) .into_any_element() as AnyElement, ] }) } } + +pub fn open(store: Entity, root: Event, cx: &mut App) { + open_global(UpdatePrDialog::new(store, root, cx), cx); +} diff --git a/crates/signed_app/src/pull_requests/helpers.rs b/crates/signed_app/src/pull_requests/helpers.rs index b867a18..8c69c59 100644 --- a/crates/signed_app/src/pull_requests/helpers.rs +++ b/crates/signed_app/src/pull_requests/helpers.rs @@ -1,5 +1,12 @@ -use gpui::SharedString; -use signed_core::RepoStatus; +use std::path::PathBuf; + +use gpui::{SharedString, WeakEntity}; +use gpui_native_kit::menu::MenuItem; +use nostr::prelude::PublicKey; +use signed_core::{Announcement, RepoAddr, RepoStatus}; +use utils::middle_truncate; + +use super::NewPullRequestView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PullRequestFilter { @@ -32,20 +39,6 @@ impl PullRequestFilter { } } -pub fn pr_label(repo_name: &SharedString, pr_id: &str) -> SharedString { - if repo_name.is_empty() { - SharedString::from(pr_id) - } else { - SharedString::from(format!("{repo_name}/{pr_id}")) - } -} - -use std::path::PathBuf; - -use nostr::prelude::PublicKey; -use signed_core::Announcement; -use utils::middle_truncate; - pub struct ForkCompare { pub announcement: Announcement, pub namespace: String, @@ -62,6 +55,14 @@ impl ForkCompare { } } +pub fn pr_label(repo_name: &SharedString, pr_id: &str) -> SharedString { + if repo_name.is_empty() { + SharedString::from(pr_id) + } else { + SharedString::from(format!("{repo_name}/{pr_id}")) + } +} + pub fn fork_display_name(announcement: &Announcement) -> SharedString { announcement .name @@ -77,3 +78,74 @@ pub fn shorten_owner(owner: &PublicKey) -> String { pub fn truncate_label(label: &str) -> SharedString { SharedString::from(middle_truncate(label, 18, 0)) } + +pub fn source_menu_items( + view: WeakEntity, + associated: Vec, + active_path: Option, + candidates: Vec, + user: Option, + active_fork: Option, +) -> Vec { + let mut items = Vec::new(); + for path in associated { + let label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let target = view.clone(); + let path_for_action = path.clone(); + items.push( + MenuItem::new(truncate_label(&label)) + .checked(active_path.as_ref() == Some(&path)) + .handler(move |window, cx| { + let path = path_for_action.clone(); + if let Err(error) = + target.update(cx, |this, cx| this.apply_folder_path(path, window, cx)) + { + log::warn!("failed to apply checkout: {error}"); + } + }), + ); + } + + let folder_view = view.clone(); + items.push( + MenuItem::new("Choose another folder…").handler(move |window, cx| { + if let Err(error) = folder_view.update(cx, |this, cx| this.choose_checkout(window, cx)) + { + log::warn!("failed to choose checkout: {error}"); + } + }), + ); + items.push(MenuItem::separator()); + + if candidates.is_empty() { + items.push(MenuItem::new("No announced forks of this repository").disabled(true)); + } else { + for candidate in candidates { + let subtitle = if Some(candidate.owner) == user { + SharedString::from("Your fork") + } else { + SharedString::from(format!("by {}", shorten_owner(&candidate.owner))) + }; + let title = format!("{} — {}", fork_display_name(&candidate), subtitle); + let target = view.clone(); + let checked = active_fork == Some(candidate.addr()); + let announcement = candidate; + items.push( + MenuItem::new(truncate_label(&title)) + .checked(checked) + .handler(move |window, cx| { + let announcement = announcement.clone(); + if let Err(error) = + target.update(cx, |this, cx| this.choose_fork(announcement, window, cx)) + { + log::warn!("failed to choose fork: {error}"); + } + }), + ); + } + } + items +} diff --git a/crates/signed_app/src/pull_requests/mod.rs b/crates/signed_app/src/pull_requests/mod.rs index 1f79e96..d647892 100644 --- a/crates/signed_app/src/pull_requests/mod.rs +++ b/crates/signed_app/src/pull_requests/mod.rs @@ -64,9 +64,9 @@ impl PullRequestsView { }); cx.defer(move |cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { this.rebuild(cx); - }); + })); }); Self { @@ -255,18 +255,18 @@ impl PullRequestsView { } }) .on_open_change(move |open, _, cx| { - let _ = menu_open_weak.update(cx, |this, cx| { + crate::shared::log_dropped(menu_open_weak.update(cx, |this, cx| { this.new_pr_menu_open = open; cx.notify(); - }); + })); }) .items(move |_, _| { vec![MenuItem::new("Send Patch").handler({ let weak = patch_weak.clone(); move |window, cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { this.open_send_patch(window, cx); - }); + })); } })] }), diff --git a/crates/signed_app/src/pull_requests/new.rs b/crates/signed_app/src/pull_requests/new.rs index 0ebe8e7..47aa62d 100644 --- a/crates/signed_app/src/pull_requests/new.rs +++ b/crates/signed_app/src/pull_requests/new.rs @@ -1,11 +1,11 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; 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, - UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, + ParentElement as _, Render, SharedString, Styled as _, Subscription, UniformListScrollHandle, + WeakEntity, Window, div, px, uniform_list, }; use gpui_native_kit::button::Button; use gpui_native_kit::combobox::{Combobox, ComboboxState}; @@ -14,45 +14,45 @@ use gpui_native_kit::data::tabs::{Tab, Tabs}; use gpui_native_kit::icons::{Icon, IconRole}; use gpui_native_kit::input::{Input, InputState}; use gpui_native_kit::layout::dock::{DockState, Panel}; -use gpui_native_kit::menu::{Menu, MenuItem}; +use gpui_native_kit::menu::Menu; use gpui_native_kit::scrollbar::Scrollbar; use gpui_native_kit::spinner::Spinner; use gpui_native_kit::theme::ActiveTheme as _; use metadata::ProfileDirectory; -use nostr::prelude::Url; -use signed_core::Announcement; -use signed_git::{GitCache, Repo}; -use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; +use signed_state::{CheckoutsStore, RepoStore}; -use super::helpers::{ForkCompare, fork_display_name, shorten_owner, truncate_label}; +use super::helpers::{ForkCompare, fork_display_name, truncate_label}; use crate::repo::commit_diff::{CommitDiffView, DiffPane}; use crate::repo::helpers::commit_row; use crate::shared::widgets::empty_state; +mod source; + pub struct NewPullRequestView { dock: WeakEntity, panel_id: SharedString, - store: Entity, + pub(super) store: Entity, repo_name: SharedString, - repo_path: Option, - branches: Vec, - fork: Option, - base: SharedString, - compare: SharedString, - base_select: Entity>, - compare_select: Entity>, + pub(super) repo_path: Option, + pub(super) branches: Vec, + pub(super) fork: Option, + pub(super) base: SharedString, + pub(super) compare: SharedString, + pub(super) base_select: Entity>, + pub(super) compare_select: Entity>, subject: Entity, description: Entity, - merge_base: Option, - commits: Option>, - loading: bool, - error: Option, + pub(super) merge_base: Option, + pub(super) commits: Option>, + pub(super) loading: bool, + pub(super) error: Option, submitting: bool, - compare_generation: u64, + pub(super) compare_generation: u64, + pub(super) fork_generation: u64, active_tab: usize, - pane: Entity, + pub(super) pane: Entity, scroll_handle: UniformListScrollHandle, - tasks: Vec>>, + pub(super) tasks: Vec>>, directory: Entity, source_open: bool, _subscriptions: Vec, @@ -80,10 +80,13 @@ impl NewPullRequestView { let weak = weak.clone(); move |value, _window, cx| { if let Some(base) = value { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { + if this.base == base { + return; + } this.base = base; this.reload_compare(cx); - }); + })); } } }), @@ -98,10 +101,13 @@ impl NewPullRequestView { let weak = weak.clone(); move |value, _window, cx| { if let Some(compare) = value { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { + if this.compare == compare { + return; + } this.compare = compare; this.reload_compare(cx); - }); + })); } } }), @@ -115,7 +121,7 @@ impl NewPullRequestView { } window.defer(cx, move |window, cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { let Some(addr) = this.store.read(cx).addr().cloned() else { return; }; @@ -130,7 +136,7 @@ impl NewPullRequestView { }; this.apply_folder_path(path, window, cx); - }); + })); }); Self { @@ -153,6 +159,7 @@ impl NewPullRequestView { error: None, submitting: false, compare_generation: 0, + fork_generation: 0, active_tab: 0, pane, scroll_handle: UniformListScrollHandle::new(), @@ -163,363 +170,6 @@ impl NewPullRequestView { } } - fn has_source(&self) -> bool { - self.repo_path.is_some() || self.fork.is_some() - } - - fn work_path(&self) -> Option { - match &self.fork { - Some(fork) => Some(fork.mirror_path.clone()), - None => self.repo_path.clone(), - } - } - - fn base_ref(&self) -> String { - match &self.fork { - Some(_) => ForkCompare::base_ref(&self.base), - None => self.base.to_string(), - } - } - - fn compare_ref(&self) -> String { - match &self.fork { - Some(fork) => fork.compare_ref(&self.compare), - None => self.compare.to_string(), - } - } - - fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context) { - let prompt = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Choose local checkout".into()), - }); - let task: gpui::Task> = - cx.spawn_in(window, async move |this, cx| { - let picked = match prompt.await { - Ok(Ok(Some(mut paths))) => paths.pop(), - _ => None, - }; - let Some(path) = picked else { - return Ok(()); - }; - this.update_in(cx, |this, window, cx| { - this.apply_folder_path(path, window, cx) - })?; - Ok(()) - }); - self.tasks.push(task); - } - - fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { - let path_string = path.to_string_lossy().to_string(); - let info = Repo::try_open(&path).and_then(|repo| { - let branches = repo.branches().ok()?; - let current = repo.current_branch(); - Some((branches, current)) - }); - self.apply_checkout(path_string, info, window, cx); - } - - fn apply_checkout( - &mut self, - path: String, - info: Option<(Vec, Option)>, - window: &mut Window, - cx: &mut Context, - ) { - self.fork = None; - let Some((branches, current)) = info else { - self.error = Some("The chosen folder is not a git repository".into()); - self.repo_path = None; - self.branches.clear(); - self.merge_base = None; - self.commits = None; - self.pane.update(cx, |pane, cx| pane.clear(cx)); - cx.notify(); - return; - }; - if branches.is_empty() { - self.error = Some("The repository has no branches yet".into()); - self.repo_path = None; - self.branches.clear(); - cx.notify(); - return; - } - - let announced = self.store.read(cx).head.clone(); - let base = announced - .as_ref() - .filter(|branch| branches.contains(branch)) - .cloned() - .or_else(|| branches.iter().find(|branch| *branch == "main").cloned()) - .unwrap_or_else(|| branches[0].clone()); - let compare = current - .filter(|branch| branches.contains(branch)) - .unwrap_or_else(|| base.clone()); - - self.repo_path = Some(PathBuf::from(&path)); - self.error = None; - self.branches = branches.into_iter().map(SharedString::from).collect(); - - if let Some(addr) = self.store.read(cx).addr().cloned() { - CheckoutsStore::global(cx).update(cx, |store, cx| { - store.record(PathBuf::from(&path), addr, cx); - }); - } - - self.base = SharedString::from(base); - self.compare = SharedString::from(compare); - self.sync_selectors(window, cx); - self.reload_compare(cx); - } - - fn sync_selectors(&mut self, window: &mut Window, cx: &mut Context) { - let options: Vec<(SharedString, SharedString)> = self - .branches - .iter() - .map(|branch| (branch.clone(), branch.clone())) - .collect(); - let base = self.base.clone(); - let compare = self.compare.clone(); - - self.base_select.update(cx, |state, cx| { - state.set_options(options.clone(), cx); - state.set_selected(Some(base), window, cx); - }); - self.compare_select.update(cx, |state, cx| { - state.set_options(options, cx); - state.set_selected(Some(compare), window, cx); - }); - } - - fn fork_candidates(&self, cx: &App) -> Vec { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return Vec::new(); - }; - let euc = self - .store - .read(cx) - .announcement - .as_ref() - .and_then(|a| a.euc.clone()); - let user = Backend::global(cx).read(cx).current_user(); - let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); - Announcement::forks_in(&announcements, &addr, euc.as_deref(), user) - .into_iter() - .cloned() - .collect() - } - - fn choose_fork( - &mut self, - announcement: Announcement, - window: &mut Window, - cx: &mut Context, - ) { - let Some(base) = self.store.read(cx).addr().cloned() else { - return; - }; - let mirror_path = Mirrors::path(&base); - let namespace = GitCache::fork_namespace(&announcement); - let clone_urls: Vec = announcement.clone.clone(); - let base_clone_urls: Vec = self - .store - .read(cx) - .announcement - .as_ref() - .map(|a| a.clone.clone()) - .unwrap_or_default(); - let expected = self.fork.as_ref().map(|fork| fork.announcement.addr()); - let handle = window.window_handle(); - - self.loading = true; - self.error = None; - cx.notify(); - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn({ - let namespace = namespace.clone(); - let mirror_path = mirror_path.clone(); - async move { - Mirrors::ensure(&base, &base_clone_urls)?; - let repo = Repo::open(&mirror_path)?; - repo.delete_refs_with_prefix("refs/fork")?; - repo.fetch_refs( - &clone_urls, - &format!("+refs/heads/*:refs/fork/{namespace}/*"), - )?; - let strip = |refs: Vec, prefix: &str| { - let mut names: Vec = refs - .into_iter() - .filter_map(|name| { - name.strip_prefix(prefix) - .map(|rest| rest.trim_start_matches('/').to_owned()) - }) - .filter(|name| !name.is_empty()) - .collect(); - names.sort(); - names - }; - let base_branches = strip( - repo.refs_with_prefix("refs/remotes/origin")?, - "refs/remotes/origin", - ); - let compare_branches = strip( - repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?, - &format!("refs/fork/{namespace}"), - ); - Ok::<_, anyhow::Error>((base_branches, compare_branches)) - } - }) - .await; - - if let Err(error) = cx.update_window(handle, |_, window, cx| { - this.update(cx, |this, cx| { - let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); - if applied != expected { - this.loading = false; - cx.notify(); - return; - } - this.apply_fork(announcement, mirror_path, namespace, result, window, cx); - }) - }) { - log::warn!("failed to apply fork: {error}"); - } - Ok(()) - }); - self.tasks.push(task); - } - - fn apply_fork( - &mut self, - announcement: Announcement, - mirror_path: PathBuf, - namespace: String, - result: Result<(Vec, Vec), anyhow::Error>, - window: &mut Window, - cx: &mut Context, - ) { - self.loading = false; - let (base_branches, compare_branches) = match result { - Ok(branches) => branches, - Err(error) => { - self.error = Some(format!("Could not compare against the fork: {error}").into()); - cx.notify(); - return; - } - }; - if compare_branches.is_empty() { - self.error = Some("The fork has no branches to compare".into()); - cx.notify(); - return; - } - if base_branches.is_empty() { - self.error = Some("Could not list the target repository's branches.".into()); - cx.notify(); - return; - } - - let announced = self.store.read(cx).head.clone(); - let base = announced - .filter(|branch| base_branches.contains(branch)) - .map(SharedString::from) - .or_else(|| { - base_branches - .iter() - .find(|branch| branch.as_str() == "main") - .map(|branch| SharedString::from(branch.clone())) - }) - .unwrap_or_else(|| SharedString::from(base_branches[0].clone())); - let compare = compare_branches - .iter() - .find(|branch| branch.as_str() == "main") - .map(|branch| SharedString::from(branch.clone())) - .unwrap_or_else(|| SharedString::from(compare_branches[0].clone())); - - self.fork = Some(ForkCompare { - announcement, - namespace, - mirror_path, - }); - self.error = None; - self.base = base; - self.compare = compare; - self.branches = base_branches.into_iter().map(SharedString::from).collect(); - self.sync_selectors(window, cx); - self.reload_compare(cx); - } - - fn reload_compare(&mut self, cx: &mut Context) { - let Some(repo_path) = self.work_path() else { - return; - }; - let base = self.base_ref(); - let compare = self.compare_ref(); - let base_name = self.base.to_string(); - let compare_name = self.compare.to_string(); - - self.loading = true; - self.error = None; - self.compare_generation += 1; - let generation = self.compare_generation; - cx.notify(); - - if base == compare { - self.loading = false; - self.merge_base = None; - self.commits = None; - self.pane.update(cx, |pane, cx| pane.clear(cx)); - self.error = Some("Choose different base and compare branches".into()); - cx.notify(); - return; - } - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn({ - async move { - let repo = Repo::open(Path::new(&repo_path))?; - let merge_base = repo.merge_base(&base, &compare)?.ok_or_else(|| { - anyhow::anyhow!( - "{base_name} and {compare_name} share no common ancestor" - ) - })?; - let commits = repo.commit_range(&merge_base, &compare)?; - let diff = repo.range_diff(&merge_base, &compare)?; - Ok::<_, anyhow::Error>((merge_base, commits, diff)) - } - }) - .await; - - this.update(cx, |this, cx| { - if generation != this.compare_generation { - return; - } - this.loading = false; - match result { - Ok((merge_base, commits, diff)) => { - this.merge_base = Some(merge_base); - this.commits = Some(commits); - this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); - } - Err(error) => { - this.merge_base = None; - this.commits = None; - this.pane.update(cx, |pane, cx| pane.clear(cx)); - this.error = Some(error.to_string().into()); - } - } - cx.notify(); - })?; - Ok(()) - }); - self.tasks.push(task); - } - fn submit(&mut self, _window: &mut Window, cx: &mut Context) { if self.submitting || self.loading { return; @@ -566,7 +216,7 @@ impl NewPullRequestView { this.update(cx, |this, _cx| { this.submitting = false; })?; - let _ = dock.update(cx, |dock, cx| dock.close_panel(&panel_id, cx)); + crate::shared::log_dropped(dock.update(cx, |dock, cx| dock.close_panel(&panel_id, cx))); Ok(()) }); self.tasks.push(task); @@ -619,7 +269,19 @@ impl NewPullRequestView { fn render_compare_bar(&self, cx: &mut Context) -> AnyElement { let weak = cx.entity().downgrade(); let weak_menu = cx.entity().downgrade(); - let source_items = self.source_menu_items(cx, cx.entity().downgrade()); + let source_view = cx.entity().downgrade(); + let associated = self + .store + .read(cx) + .addr() + .map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr)) + .unwrap_or_default(); + let active_path = (self.fork.is_none()) + .then(|| self.repo_path.clone()) + .flatten(); + let candidates = self.fork_candidates(cx); + let user = signed_state::Backend::global(cx).read(cx).current_user(); + let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); let can_submit = self.submit_enabled(cx); let source_label = match &self.fork { Some(fork) => truncate_label(&fork_display_name(&fork.announcement)), @@ -653,10 +315,10 @@ impl NewPullRequestView { Menu::new("pr-source") .open(self.source_open) .on_change(move |open, _, cx| { - let _ = weak_menu.update(cx, |this, cx| { + crate::shared::log_dropped(weak_menu.update(cx, |this, cx| { this.source_open = open; cx.notify(); - }); + })); }) .trigger(move |_, _| { Button::new("compare-source") @@ -664,7 +326,16 @@ impl NewPullRequestView { .variant(gpui_native_kit::button::ButtonVariant::Secondary) .into_any_element() }) - .items(move |_, _| source_items.clone()), + .items(move |_, _| { + super::helpers::source_menu_items( + source_view.clone(), + associated.clone(), + active_path.clone(), + candidates.clone(), + user, + active_fork.clone(), + ) + }), ), ) .when_some(refresh_fork, |this, fork| { @@ -684,76 +355,14 @@ impl NewPullRequestView { .loading(self.submitting) .disabled(!can_submit) .on_click(move |_, window, cx| { - let _ = weak.update(cx, |this, cx| this.submit(window, cx)); + crate::shared::log_dropped( + weak.update(cx, |this, cx| this.submit(window, cx)), + ); }), ) .into_any_element() } - fn source_menu_items(&self, cx: &App, view: WeakEntity) -> Vec { - let associated = self - .store - .read(cx) - .addr() - .map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr)) - .unwrap_or_default(); - let active_path = (self.fork.is_none()) - .then(|| self.repo_path.clone()) - .flatten(); - let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); - - let mut items = Vec::new(); - for path in associated { - let label = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); - let view = view.clone(); - items.push( - MenuItem::new(truncate_label(&label)) - .checked(active_path.as_ref() == Some(&path)) - .handler(move |window, cx| { - let path = path.clone(); - let _ = - view.update(cx, |this, cx| this.apply_folder_path(path, window, cx)); - }), - ); - } - let folder_view = view.clone(); - items.push( - MenuItem::new("Choose another folder…").handler(move |window, cx| { - let _ = folder_view.update(cx, |this, cx| this.choose_checkout(window, cx)); - }), - ); - items.push(MenuItem::separator()); - - let candidates = self.fork_candidates(cx); - if candidates.is_empty() { - items.push(MenuItem::new("No announced forks of this repository").disabled(true)); - } else { - let user = Backend::global(cx).read(cx).current_user(); - for candidate in candidates { - let subtitle = if Some(candidate.owner) == user { - SharedString::from("Your fork") - } else { - SharedString::from(format!("by {}", shorten_owner(&candidate.owner))) - }; - let title = format!("{} — {}", fork_display_name(&candidate), subtitle); - let view = view.clone(); - items.push( - MenuItem::new(truncate_label(&title)) - .checked(active_fork == Some(candidate.addr())) - .handler(move |window, cx| { - let announcement = candidate.clone(); - let _ = view - .update(cx, |this, cx| this.choose_fork(announcement, window, cx)); - }), - ); - } - } - items - } - fn render_tabs(&self, cx: &mut Context) -> AnyElement { let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len()); let commits = self.commits.as_ref().map_or(0, |commits| commits.len()); @@ -770,10 +379,10 @@ impl NewPullRequestView { ] }) .on_change(move |index, _, cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { this.active_tab = index; cx.notify(); - }); + })); }) .into_any_element() } @@ -824,9 +433,9 @@ impl NewPullRequestView { ix, &commit, move |window, cx| { - let _ = view.update(cx, |this, cx| { + crate::shared::log_dropped(view.update(cx, |this, cx| { this.open_commit_diff(&id, window, cx); - }); + })); }, cx, ) diff --git a/crates/signed_app/src/pull_requests/new/source.rs b/crates/signed_app/src/pull_requests/new/source.rs new file mode 100644 index 0000000..e151c36 --- /dev/null +++ b/crates/signed_app/src/pull_requests/new/source.rs @@ -0,0 +1,397 @@ +use std::path::{Path, PathBuf}; + +use gpui::{App, AppContext as _, Context, PathPromptOptions, SharedString, Window}; +use nostr::prelude::Url; +use signed_core::Announcement; +use signed_git::{GitCache, Repo}; +use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore}; + +use super::NewPullRequestView; +use crate::pull_requests::helpers::ForkCompare; + +impl NewPullRequestView { + pub(super) fn has_source(&self) -> bool { + self.repo_path.is_some() || self.fork.is_some() + } + + pub(super) fn work_path(&self) -> Option { + match &self.fork { + Some(fork) => Some(fork.mirror_path.clone()), + None => self.repo_path.clone(), + } + } + + pub(super) fn base_ref(&self) -> String { + match &self.fork { + Some(_) => ForkCompare::base_ref(&self.base), + None => self.base.to_string(), + } + } + + pub(super) fn compare_ref(&self) -> String { + match &self.fork { + Some(fork) => fork.compare_ref(&self.compare), + None => self.compare.to_string(), + } + } + + pub(crate) fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context) { + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose local checkout".into()), + }); + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(path) = picked else { + return Ok(()); + }; + this.update_in(cx, |this, window, cx| { + this.apply_folder_path(path, window, cx) + })?; + Ok(()) + }); + self.tasks.push(task); + } + + pub(crate) fn apply_folder_path( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + self.fork_generation = self.fork_generation.wrapping_add(1); + let generation = self.fork_generation; + let path_string = path.to_string_lossy().to_string(); + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + let info = cx + .background_spawn({ + let path = path.clone(); + async move { + let repo = Repo::try_open(&path)?; + match repo.branches() { + Ok(branches) => Some((branches, repo.current_branch())), + Err(error) => { + log::warn!("failed to list branches: {error}"); + None + } + } + } + }) + .await; + this.update_in(cx, |this, window, cx| { + if generation != this.fork_generation { + return; + } + this.apply_checkout(path_string, info, window, cx); + })?; + Ok(()) + }); + self.tasks.push(task); + } + + fn apply_checkout( + &mut self, + path: String, + info: Option<(Vec, Option)>, + window: &mut Window, + cx: &mut Context, + ) { + self.fork = None; + let Some((branches, current)) = info else { + self.error = Some("The chosen folder is not a git repository".into()); + self.repo_path = None; + self.branches.clear(); + self.merge_base = None; + self.commits = None; + self.pane.update(cx, |pane, cx| pane.clear(cx)); + cx.notify(); + return; + }; + if branches.is_empty() { + self.error = Some("The repository has no branches yet".into()); + self.repo_path = None; + self.branches.clear(); + self.merge_base = None; + self.commits = None; + self.pane.update(cx, |pane, cx| pane.clear(cx)); + cx.notify(); + return; + } + + let announced = self.store.read(cx).head.clone(); + let base = announced + .as_ref() + .filter(|branch| branches.contains(branch)) + .cloned() + .or_else(|| branches.iter().find(|branch| *branch == "main").cloned()) + .unwrap_or_else(|| branches[0].clone()); + let compare = current + .filter(|branch| branches.contains(branch)) + .unwrap_or_else(|| base.clone()); + + self.repo_path = Some(PathBuf::from(&path)); + self.error = None; + self.branches = branches.into_iter().map(SharedString::from).collect(); + + if let Some(addr) = self.store.read(cx).addr().cloned() { + CheckoutsStore::global(cx).update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + } + + self.base = SharedString::from(base); + self.compare = SharedString::from(compare); + self.sync_selectors(window, cx); + self.reload_compare(cx); + } + + fn sync_selectors(&mut self, window: &mut Window, cx: &mut Context) { + let options: Vec<(SharedString, SharedString)> = self + .branches + .iter() + .map(|branch| (branch.clone(), branch.clone())) + .collect(); + let base = self.base.clone(); + let compare = self.compare.clone(); + + self.base_select.update(cx, |state, cx| { + state.set_options(options.clone(), cx); + state.set_selected(Some(base), window, cx); + }); + self.compare_select.update(cx, |state, cx| { + state.set_options(options, cx); + state.set_selected(Some(compare), window, cx); + }); + } + + pub(super) fn fork_candidates(&self, cx: &App) -> Vec { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return Vec::new(); + }; + let euc = self + .store + .read(cx) + .announcement + .as_ref() + .and_then(|a| a.euc.clone()); + let user = Backend::global(cx).read(cx).current_user(); + let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); + Announcement::forks_in(&announcements, &addr, euc.as_deref(), user) + .into_iter() + .cloned() + .collect() + } + + pub(crate) fn choose_fork( + &mut self, + announcement: Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let Some(base) = self.store.read(cx).addr().cloned() else { + return; + }; + let mirror_path = Mirrors::path(&base); + let namespace = GitCache::fork_namespace(&announcement); + let clone_urls: Vec = announcement.clone.clone(); + let base_clone_urls: Vec = self + .store + .read(cx) + .announcement + .as_ref() + .map(|a| a.clone.clone()) + .unwrap_or_default(); + self.fork_generation = self.fork_generation.wrapping_add(1); + let generation = self.fork_generation; + let handle = window.window_handle(); + + self.loading = true; + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn({ + let namespace = namespace.clone(); + let mirror_path = mirror_path.clone(); + async move { + Mirrors::ensure(&base, &base_clone_urls)?; + let repo = Repo::open(&mirror_path)?; + repo.delete_refs_with_prefix("refs/fork")?; + repo.fetch_refs( + &clone_urls, + &format!("+refs/heads/*:refs/fork/{namespace}/*"), + )?; + let strip = |refs: Vec, prefix: &str| { + let mut names: Vec = refs + .into_iter() + .filter_map(|name| { + name.strip_prefix(prefix) + .map(|rest| rest.trim_start_matches('/').to_owned()) + }) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names + }; + let base_branches = strip( + repo.refs_with_prefix("refs/remotes/origin")?, + "refs/remotes/origin", + ); + let compare_branches = strip( + repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?, + &format!("refs/fork/{namespace}"), + ); + Ok::<_, anyhow::Error>((base_branches, compare_branches)) + } + }) + .await; + + if let Err(error) = cx.update_window(handle, |_, window, cx| { + this.update(cx, |this, cx| { + if generation != this.fork_generation { + return; + } + this.apply_fork(announcement, mirror_path, namespace, result, window, cx); + }) + }) { + log::warn!("failed to apply fork: {error}"); + } + Ok(()) + }); + self.tasks.push(task); + } + + fn apply_fork( + &mut self, + announcement: Announcement, + mirror_path: PathBuf, + namespace: String, + result: Result<(Vec, Vec), anyhow::Error>, + window: &mut Window, + cx: &mut Context, + ) { + self.loading = false; + let (base_branches, compare_branches) = match result { + Ok(branches) => branches, + Err(error) => { + self.error = Some(format!("Could not compare against the fork: {error}").into()); + cx.notify(); + return; + } + }; + if compare_branches.is_empty() { + self.error = Some("The fork has no branches to compare".into()); + cx.notify(); + return; + } + if base_branches.is_empty() { + self.error = Some("Could not list the target repository's branches.".into()); + cx.notify(); + return; + } + + let announced = self.store.read(cx).head.clone(); + let base = announced + .filter(|branch| base_branches.contains(branch)) + .map(SharedString::from) + .or_else(|| { + base_branches + .iter() + .find(|branch| branch.as_str() == "main") + .map(|branch| SharedString::from(branch.clone())) + }) + .unwrap_or_else(|| SharedString::from(base_branches[0].clone())); + let compare = compare_branches + .iter() + .find(|branch| branch.as_str() == "main") + .map(|branch| SharedString::from(branch.clone())) + .unwrap_or_else(|| SharedString::from(compare_branches[0].clone())); + + self.fork = Some(ForkCompare { + announcement, + namespace, + mirror_path, + }); + self.error = None; + self.base = base; + self.compare = compare; + self.branches = base_branches.into_iter().map(SharedString::from).collect(); + self.sync_selectors(window, cx); + self.reload_compare(cx); + } + + pub(super) fn reload_compare(&mut self, cx: &mut Context) { + let Some(repo_path) = self.work_path() else { + return; + }; + let base = self.base_ref(); + let compare = self.compare_ref(); + let base_name = self.base.to_string(); + let compare_name = self.compare.to_string(); + + self.loading = true; + self.error = None; + self.compare_generation += 1; + let generation = self.compare_generation; + cx.notify(); + + if base == compare { + self.loading = false; + self.merge_base = None; + self.commits = None; + self.pane.update(cx, |pane, cx| pane.clear(cx)); + self.error = Some("Choose different base and compare branches".into()); + cx.notify(); + return; + } + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn({ + async move { + let repo = Repo::open(Path::new(&repo_path))?; + let merge_base = repo.merge_base(&base, &compare)?.ok_or_else(|| { + anyhow::anyhow!( + "{base_name} and {compare_name} share no common ancestor" + ) + })?; + let commits = repo.commit_range(&merge_base, &compare)?; + let diff = repo.range_diff(&merge_base, &compare)?; + Ok::<_, anyhow::Error>((merge_base, commits, diff)) + } + }) + .await; + + this.update(cx, |this, cx| { + if generation != this.compare_generation { + return; + } + this.loading = false; + match result { + Ok((merge_base, commits, diff)) => { + this.merge_base = Some(merge_base); + this.commits = Some(commits); + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + } + Err(error) => { + this.merge_base = None; + this.commits = None; + this.pane.update(cx, |pane, cx| pane.clear(cx)); + this.error = Some(error.to_string().into()); + } + } + cx.notify(); + })?; + Ok(()) + }); + self.tasks.push(task); + } +} diff --git a/crates/signed_app/src/pull_requests/send_patch.rs b/crates/signed_app/src/pull_requests/send_patch.rs index 7b03471..8f3e606 100644 --- a/crates/signed_app/src/pull_requests/send_patch.rs +++ b/crates/signed_app/src/pull_requests/send_patch.rs @@ -102,9 +102,10 @@ impl SendPatchView { } let panel_id = self.panel_id.clone(); - let _ = self - .dock - .update(cx, |dock, cx| dock.close_panel(&panel_id, cx)); + crate::shared::log_dropped( + self.dock + .update(cx, |dock, cx| dock.close_panel(&panel_id, cx)), + ); } fn render_footer(&self, cx: &Context) -> AnyElement { @@ -127,7 +128,7 @@ impl SendPatchView { .loading(self.submitting) .disabled(!can_submit) .on_click(move |_, _, cx| { - let _ = weak.update(cx, |this, cx| this.submit(cx)); + crate::shared::log_dropped(weak.update(cx, |this, cx| this.submit(cx))); }), ) .into_any_element() diff --git a/crates/signed_app/src/repo/actions.rs b/crates/signed_app/src/repo/actions.rs index af1a827..056a103 100644 --- a/crates/signed_app/src/repo/actions.rs +++ b/crates/signed_app/src/repo/actions.rs @@ -7,6 +7,13 @@ use signed_state::RepoStore; use crate::repo::RepoDetailView; use crate::shared::panels; +/// An item of a repository to open from outside its detail panel. +pub enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + /// Opens a repository as a dock panel, keyed by its address. pub fn open_repo_panel( dock: &WeakEntity, @@ -33,13 +40,6 @@ pub fn open_repo_panel( ); } -/// An item of a repository to open from outside its detail panel. -pub enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch, -} - /// Opens an issue or pull request using a store built here. pub fn open_repo_item( dock: &WeakEntity, diff --git a/crates/signed_app/src/repo/dialogs/create.rs b/crates/signed_app/src/repo/dialogs/create.rs index 2841dab..7555b30 100644 --- a/crates/signed_app/src/repo/dialogs/create.rs +++ b/crates/signed_app/src/repo/dialogs/create.rs @@ -222,7 +222,9 @@ impl Render for CreateRepoDialog { .loading(busy) .disabled(busy) .on_click(move |_, window, cx| { - let _ = this.update(cx, |this, cx| this.create(window, cx)); + crate::shared::log_dropped( + this.update(cx, |this, cx| this.create(window, cx)), + ); }) .into_any_element(); diff --git a/crates/signed_app/src/repo/dialogs/init.rs b/crates/signed_app/src/repo/dialogs/init.rs index 7cddb58..e9a7943 100644 --- a/crates/signed_app/src/repo/dialogs/init.rs +++ b/crates/signed_app/src/repo/dialogs/init.rs @@ -243,7 +243,9 @@ impl Render for InitRepoDialog { .loading(busy) .disabled(busy) .on_click(move |_, window, cx| { - let _ = this.update(cx, |this, cx| this.init_repository(window, cx)); + crate::shared::log_dropped( + this.update(cx, |this, cx| this.init_repository(window, cx)), + ); }) .into_any_element(); diff --git a/crates/signed_app/src/repo/files.rs b/crates/signed_app/src/repo/files.rs index f53eae2..f6f7aac 100644 --- a/crates/signed_app/src/repo/files.rs +++ b/crates/signed_app/src/repo/files.rs @@ -69,8 +69,7 @@ pub struct RepoFilesView { } impl RepoFilesView { - pub fn new(cx: &mut Context) -> Self { - let _ = cx; + pub fn new(_cx: &mut Context) -> Self { Self { seeds: Vec::new(), expanded: HashSet::new(), @@ -448,13 +447,7 @@ impl RepoFilesView { .into_any_element() } - fn set_code( - &mut self, - path: SharedString, - text: &str, - window: &mut Window, - cx: &mut Context, - ) { + fn set_code(&mut self, path: SharedString, text: &str, cx: &mut Context) { let hash = source_hash(text); let language = code_language(path.as_ref()); @@ -480,7 +473,6 @@ impl RepoFilesView { } let state = cx.new(|cx| CodeViewState::new("repo-code", text, language, cx)); - let _ = window; self.code = Some(CodeView { path, state, @@ -514,7 +506,7 @@ impl RepoFilesView { 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); + self.set_code(path.into(), &text, cx); } } cx.notify(); @@ -574,7 +566,7 @@ impl RepoFilesView { }) .await; - this.update_in(cx, |this, window, cx| { + this.update_in(cx, |this, _window, cx| { this.loading_files.remove(&path); match content { @@ -590,7 +582,7 @@ impl RepoFilesView { 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.set_code(path.clone().into(), text, cx); } } this.preview_bytes += text.len(); diff --git a/crates/signed_app/src/repo/header.rs b/crates/signed_app/src/repo/header.rs new file mode 100644 index 0000000..3f02707 --- /dev/null +++ b/crates/signed_app/src/repo/header.rs @@ -0,0 +1,667 @@ +use std::collections::HashSet; + +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, AppContext as _, ClipboardItem, Context, IntoElement, ParentElement as _, + SharedString, Styled as _, Window, div, px, +}; +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 _; +use gpui_native_kit::tooltip::Tooltip; +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 crate::issues::IssuesView; +use crate::pull_requests::{NewPullRequestView, PullRequestsView, SendPatchView}; + +impl RepoDetailView { + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self + .announcement(cx) + .and_then(|announcement| announcement.upstream.as_ref()) + .and_then(|upstream| upstream.addr.clone()) + else { + return; + }; + + actions::open_repo_panel(&self.dock, &addr, None, window, cx); + } + + /// Open the dialog that publishes the local repository to NIP-34. + fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.store.read(cx).path.clone() else { + return; + }; + dialogs::init::open(local_path, cx.entity().downgrade(), window, cx); + } + + fn open_pulls(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("pulls:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id, + move |_, cx| { + cx.new(|cx| PullRequestsView::new(dock_for_build.clone(), store.clone(), cx)) + }, + window, + cx, + ); + } + + pub(super) fn open_new_pull_request(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("new-pr:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id.clone(), + move |window, cx| { + cx.new(|cx| { + NewPullRequestView::new( + dock_for_build.clone(), + id.clone(), + store.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + + fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("send-patch:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id.clone(), + move |window, cx| { + cx.new(|cx| { + SendPatchView::new( + dock_for_build.clone(), + id.clone(), + store.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + + fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + let id = SharedString::from(format!("issues:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id, + move |_, cx| cx.new(|cx| IssuesView::new(dock_for_build.clone(), store.clone(), cx)), + window, + cx, + ); + } + + pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.store.read(cx).addr().is_none() { + return self.render_local_header(cx); + } + + 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)); + + let share_naddr = share.naddr.clone(); + let share_for_menu = share.clone(); + let announcement_for_menu = announcement.clone(); + 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() + .p_4() + .w_full() + .gap_6() + .border_b_1() + .border_color(cx.theme().colors.border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(avatar) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .line_clamp(2) + .child(description), + ) + .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) + .child( + h_flex() + .mt_2() + .w_full() + .gap_1() + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Maintainers:"), + ) + .child(self.render_maintainers(cx)), + ), + ) + .child( + h_flex() + .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)) + .variant(ButtonVariant::Secondary) + .open(self.share_open) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + share_naddr.clone(), + )); + }) + .on_open_change(move |open, _, cx| { + if let Err(error) = weak_share.update(cx, |this, cx| { + this.share_open = open; + cx.notify(); + }) { + log::warn!("failed to update share menu: {error}"); + } + }) + .items(move |_, _| share_for_menu.menu()), + ) + .child( + DropdownMenu::new("repo-menu") + .icon(Icon::named(CustomIconName::EllipsisVertical)) + .variant(ButtonVariant::Secondary) + .loading(pushing) + .disabled(pushing) + .open(self.repo_menu_open) + .on_open_change(move |open, _, cx| { + if let Err(error) = weak_menu_open.update(cx, |this, cx| { + this.repo_menu_open = open; + cx.notify(); + }) { + log::warn!("failed to update repo menu: {error}"); + } + }) + .items(move |_, cx| { + let owner = Backend::global(cx) + .read(cx) + .current_user() + == Some(announcement_for_menu.owner); + + let mut items = vec![MenuItem::new("About").handler({ + let weak = weak_menu_items.clone(); + let announcement = announcement_for_menu.clone(); + move |_, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + dialogs::about::open( + announcement.clone(), + this.directory.clone(), + cx, + ); + }) { + log::warn!("failed to open about: {error}"); + } + } + })]; + + if owner { + items.push(MenuItem::new("Republish").handler({ + let weak = weak_menu_items.clone(); + move |window, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.push_repository(window, cx); + }) { + log::warn!("failed to republish: {error}"); + } + } + })); + items.push(MenuItem::new("Reset remote to local").handler({ + let weak = weak_menu_items.clone(); + move |window, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.reset_remote(window, cx); + }) { + log::warn!("failed to reset remote: {error}"); + } + } + })); + items.push(MenuItem::separator()); + items.push( + MenuItem::new("Delete") + .destructive(true) + .handler({ + let weak = weak_menu_items.clone(); + move |window, cx| { + if let Err(error) = + weak.update(cx, |this, cx| { + this.delete_repository(window, cx); + }) + { + log::warn!( + "failed to delete repository: {error}" + ); + } + } + }), + ); + } + + 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, + )) + .child(clone_group( + "Clone with nak", + "copy-nak", + &nak_for_popover, + )) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .text_color( + cx.theme().colors.muted_foreground, + ) + .child("Grasp Servers"), + ) + .when(git_for_popover.is_empty(), |this| { + this.child( + div() + .text_xs() + .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)) + .into_any_element() + } + + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = repo_display_name(self.store.read(cx)); + let path = self + .store + .read(cx) + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let avatar = Avatar::new(path.clone()); + + let bound = self.store.read(cx).nip34.clone(); + let action = match bound + .as_ref() + .filter(|binding| binding.kind == Nip34Kind::Initialized) + { + Some(binding) => bound_repo_label(binding, &self.directory, cx), + None => Button::new("init") + .icon(Icon::named(CustomIconName::Init)) + .label("Initialize on Nostr") + .tooltip(Tooltip::text("Publish this repository to Nostr")) + .on_click(cx.listener(|this, _, window, cx| { + this.open_init_dialog(window, cx); + })) + .into_any_element(), + }; + + v_flex() + .flex_none() + .px_4() + .pb_4() + .w_full() + .gap_6() + .border_b_1() + .border_color(cx.theme().colors.border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(avatar) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(path), + ), + ) + .child(action), + ) + .child(self.render_tabs(cx)) + .into_any_element() + } + + fn render_tabs(&self, cx: &mut Context) -> AnyElement { + let count = self.history.read(cx).commit_count(); + let muted = cx.theme().colors.muted_foreground; + let weak = cx.entity().downgrade(); + let commits_label = match count { + Some(count) => SharedString::from(format!("Commits ({count})")), + None => SharedString::from("Commits"), + }; + + Tabs::new("repo-tabs") + .selected(self.active_tab) + .tabs(move |_, _| { + vec![ + Tab::new("files", "Files") + .lead(Icon::named(CustomIconName::GitFile).color(muted)), + Tab::new("commits", commits_label.clone()) + .lead(Icon::named(CustomIconName::GitCommit).color(muted)), + ] + }) + .on_change(move |index, _, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.active_tab = index; + cx.notify(); + }) { + log::warn!("failed to switch repo tab: {error}"); + } + }) + .into_any_element() + } + + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let Some(announcement) = self.announcement(cx) else { + return div().into_any_element(); + }; + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| key != &announcement.owner && seen.insert(*key)) + .collect(); + + let owner = self.directory.read(cx).profile(&announcement.owner, cx); + let owner_name = owner.name(); + + h_flex() + .w_full() + .gap_3() + .child( + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child( + Avatar::new(announcement.owner) + .name(owner_name.clone()) + .picture(owner.picture()) + .small(), + ) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(SharedString::from(format!("+{}", rest.len()))), + ) + }), + ) + .into_any_element() + } +} diff --git a/crates/signed_app/src/repo/helpers.rs b/crates/signed_app/src/repo/helpers.rs index 3d25378..4e920a9 100644 --- a/crates/signed_app/src/repo/helpers.rs +++ b/crates/signed_app/src/repo/helpers.rs @@ -26,6 +26,24 @@ use utils::{middle_truncate, relative_time_secs}; use super::RepoDetailView; use crate::shared::tree::{TreeItemSeed, build_tree_items}; +const GUTTER_WIDTH: f32 = 44.; +pub const DIFF_ROW_HEIGHT: f32 = 20.; +pub const COMMIT_ROW_HEIGHT: f32 = 56.; + +#[derive(Clone, Copy)] +pub(super) enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + Line { + hunk: usize, + line: usize, + }, +} + pub(super) struct RepoData { pub(super) tree: Vec, pub(super) entries: Vec, @@ -38,6 +56,67 @@ pub(super) struct RepoData { pub(super) head_commit: Option, } +#[derive(Clone)] +pub(super) struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + pub(super) naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + gitworkshop: String, + ditto: String, +} + +impl ShareTargets { + pub(super) fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) + .to_bech32() + .unwrap_or_else(|_| coordinate.clone()); + + Self { + naddr: naddr.clone(), + event_id: announcement + .event_id + .to_bech32() + .unwrap_or_else(|_| announcement.event_id.to_string()), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + pub(super) fn menu(&self) -> Vec { + let items = [ + ("GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4)), + ("Ditto", truncate_naddr_link(&self.ditto, 4)), + ("Event ID", middle_truncate(&self.event_id, 10, 10)), + ("Coordinate", middle_truncate(&self.coordinate, 10, 10)), + ]; + + let values = [ + self.gitworkshop.clone(), + self.ditto.clone(), + self.event_id.clone(), + self.coordinate.clone(), + ]; + + items + .into_iter() + .zip(values) + .map(|((label, detail), value)| { + MenuItem::new(SharedString::from(format!("{label} {detail}"))).handler( + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(value.clone())); + }, + ) + }) + .collect() + } +} + pub(super) fn clone_group(label: &str, id: &'static str, command: &SharedString) -> AnyElement { v_flex() .gap_1() @@ -106,67 +185,6 @@ pub(super) fn fork_row( }) } -#[derive(Clone)] -pub(super) struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - pub(super) naddr: String, - /// Hex ID of the announcement event itself. - event_id: String, - /// NIP-34 coordinate `30617::`. - coordinate: String, - gitworkshop: String, - ditto: String, -} - -impl ShareTargets { - pub(super) fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) - .to_bech32() - .unwrap_or_else(|_| coordinate.clone()); - - Self { - naddr: naddr.clone(), - event_id: announcement - .event_id - .to_bech32() - .unwrap_or_else(|_| announcement.event_id.to_string()), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - pub(super) fn menu(&self) -> Vec { - let items = [ - ("GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4)), - ("Ditto", truncate_naddr_link(&self.ditto, 4)), - ("Event ID", middle_truncate(&self.event_id, 10, 10)), - ("Coordinate", middle_truncate(&self.coordinate, 10, 10)), - ]; - - let values = [ - self.gitworkshop.clone(), - self.ditto.clone(), - self.event_id.clone(), - self.coordinate.clone(), - ]; - - items - .into_iter() - .zip(values) - .map(|((label, detail), value)| { - MenuItem::new(SharedString::from(format!("{label} {detail}"))).handler( - move |_, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(value.clone())); - }, - ) - }) - .collect() - } -} - pub(super) fn truncate_naddr_link(url: &str, tail: usize) -> String { let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { return url.to_string(); @@ -314,24 +332,6 @@ pub(super) fn is_markdown_path(path: &str) -> bool { }) } -const GUTTER_WIDTH: f32 = 44.; -pub const DIFF_ROW_HEIGHT: f32 = 20.; -pub const COMMIT_ROW_HEIGHT: f32 = 56.; - -#[derive(Clone, Copy)] -pub(super) enum DiffRow { - Hunk { - old_start: u32, - old_lines: u32, - new_start: u32, - new_lines: u32, - }, - Line { - hunk: usize, - line: usize, - }, -} - pub(super) fn diff_rows(file: &FileDiff) -> Vec { let mut rows = Vec::new(); for (hunk_ix, hunk) in file.hunks.iter().enumerate() { diff --git a/crates/signed_app/src/repo/mod.rs b/crates/signed_app/src/repo/mod.rs index a7d22c4..1a8ee25 100644 --- a/crates/signed_app/src/repo/mod.rs +++ b/crates/signed_app/src/repo/mod.rs @@ -5,32 +5,25 @@ use anyhow::Error; use assets::CustomIconName; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, AppContext as _, ClipboardItem, Context, Entity, InteractiveElement as _, - IntoElement, ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, - Subscription, Task, WeakEntity, Window, div, px, + AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, Subscription, Task, + WeakEntity, Window, div, px, }; use gpui_native_kit::alert::{Alert, AlertSeverity}; -use gpui_native_kit::avatar::Avatar; use gpui_native_kit::badge::Badge; 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::layout::dock::{DockState, Panel}; -use gpui_native_kit::menu::MenuItem; -use gpui_native_kit::popover::Popover; use gpui_native_kit::spinner::Spinner; use gpui_native_kit::theme::ActiveTheme as _; -use gpui_native_kit::tooltip::Tooltip; use metadata::ProfileDirectory; use nostr::prelude::{PublicKey, Url}; use signed_core::{Announcement, RepoAddr}; use signed_git::{FileCommit, GitCache, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, - Nip34Binding, Nip34Kind, PushStepStatus, RepoStore, SyncStatusDirectory, + Nip34Binding, PushStepStatus, RepoStore, SyncStatusDirectory, }; pub mod actions; @@ -38,21 +31,17 @@ pub mod banners; pub mod commit_diff; pub mod dialogs; pub mod files; +mod header; pub mod helpers; pub mod history; pub mod refs; use banners::Banners; use files::RepoFilesView; -use helpers::{ - RepoData, ShareTargets, bound_repo_label, clone_group, fork_row, load_repo_data, - nostr_clone_url, repo_display_name, -}; +use helpers::{RepoData, load_repo_data, repo_display_name}; use history::RepoHistoryView; use refs::RefSwitcher; -use crate::issues::IssuesView; -use crate::pull_requests::{NewPullRequestView, PullRequestsView, SendPatchView}; use crate::shared::tree::{build_tree_items, sorted_worktree_paths}; use crate::shared::widgets::repo_tab_avatar; @@ -65,16 +54,16 @@ pub enum RefKind { } pub struct RepoDetailView { - dock: WeakEntity, - store: Entity, + pub(super) dock: WeakEntity, + pub(super) store: Entity, /// A repository opened by address alone starts without an announcement. repo_started: bool, /// The Files tab, which owns the explorer, previews and the per-file commit map. files: Entity, /// The Commits tab, which walks HEAD and opens commit diffs. - history: Entity, + pub(super) history: Entity, /// The active tab: Files or Commits. - active_tab: usize, + pub(super) active_tab: usize, /// The checked-out worktree path. worktree: Option, loading: bool, @@ -84,14 +73,14 @@ pub struct RepoDetailView { banners: Banners, tasks: Vec>>, /// Profiles rendered by the header and maintainers. - directory: Entity, + pub(super) directory: Entity, /// Sync status feeding the sync-to-Nostr banner. sync_directory: Entity, - share_open: bool, - repo_menu_open: bool, - clone_open: bool, - issues_menu_open: bool, - pulls_menu_open: bool, + 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, } @@ -151,10 +140,10 @@ impl RepoDetailView { let store_for_attach = store.clone(); window.defer(cx, move |window, cx| { - let _ = weak.update(cx, |this, cx| { + crate::shared::log_dropped(weak.update(cx, |this, cx| { this.attach_store(&store_for_attach, window, cx); this.load_repo(window, cx); - }); + })); }); Self { @@ -406,8 +395,16 @@ impl RepoDetailView { return Ok::<_, Error>(None); }; - repo.fetch().ok(); - let moved = repo.fast_forward_branches().unwrap_or(false); + if let Err(error) = repo.fetch() { + log::debug!("best-effort fetch failed: {error}"); + } + let moved = match repo.fast_forward_branches() { + Ok(moved) => moved, + Err(error) => { + log::warn!("failed to fast-forward branches: {error}"); + false + } + }; Ok::<_, Error>(Some((moved, repo.snapshot()?))) }) .await @@ -811,640 +808,83 @@ impl RepoDetailView { } /// Open the upstream repository, the `u` tag of this fork's announcement. - pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self - .announcement(cx) - .and_then(|announcement| announcement.upstream.as_ref()) - .and_then(|upstream| upstream.addr.clone()) - else { - return; - }; + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; - actions::open_repo_panel(&self.dock, &addr, None, window, cx); - } - - /// Open the dialog that publishes the local repository to NIP-34. - fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.store.read(cx).path.clone() else { - return; - }; - dialogs::init::open(local_path, cx.entity().downgrade(), window, cx); - } - - fn open_pulls(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return; - }; - - let id = SharedString::from(format!("pulls:{addr}")); - let dock = self.dock.clone(); - let dock_for_build = dock.clone(); - let store = self.store.clone(); - - crate::shared::panels::open( - &dock, - id, - move |_, cx| { - cx.new(|cx| PullRequestsView::new(dock_for_build.clone(), store.clone(), cx)) - }, - window, - cx, - ); - } - - fn open_new_pull_request(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return; - }; - - let id = SharedString::from(format!("new-pr:{addr}")); - let dock = self.dock.clone(); - let dock_for_build = dock.clone(); - let store = self.store.clone(); - - crate::shared::panels::open( - &dock, - id.clone(), - move |window, cx| { - cx.new(|cx| { - NewPullRequestView::new( - dock_for_build.clone(), - id.clone(), - store.clone(), - window, - cx, - ) - }) - }, - window, - cx, - ); - } - - fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return; - }; - - let id = SharedString::from(format!("send-patch:{addr}")); - let dock = self.dock.clone(); - let dock_for_build = dock.clone(); - let store = self.store.clone(); - - crate::shared::panels::open( - &dock, - id.clone(), - move |window, cx| { - cx.new(|cx| { - SendPatchView::new( - dock_for_build.clone(), - id.clone(), - store.clone(), - window, - cx, - ) - }) - }, - window, - cx, - ); - } - - fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return; - }; - let id = SharedString::from(format!("issues:{addr}")); - let dock = self.dock.clone(); - let dock_for_build = dock.clone(); - let store = self.store.clone(); - - crate::shared::panels::open( - &dock, - id, - move |_, cx| cx.new(|cx| IssuesView::new(dock_for_build.clone(), store.clone(), cx)), - window, - cx, - ); - } - - fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.store.read(cx).addr().is_none() { - return self.render_local_header(cx); + if store.is_author(&user) { + return None; } - 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)); - - let share_naddr = share.naddr.clone(); - let share_for_menu = share.clone(); - let announcement_for_menu = announcement.clone(); - 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() - .p_4() - .w_full() - .gap_6() - .border_b_1() - .border_color(cx.theme().colors.border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(avatar) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().colors.muted_foreground) - .line_clamp(2) - .child(description), - ) - .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) - .child( - h_flex() - .mt_2() - .w_full() - .gap_1() - .child( - div() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .font_weight(gpui::FontWeight::SEMIBOLD) - .child("Maintainers:"), - ) - .child(self.render_maintainers(cx)), - ), - ) - .child( - h_flex() - .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| { - let _ = weak_new.update(cx, |this, cx| { - this.open_new_pull_request(window, cx); - }); - }), - MenuItem::new("Send patch").handler(move |window, cx| { - let _ = 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)) - .variant(ButtonVariant::Secondary) - .open(self.share_open) - .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - share_naddr.clone(), - )); - }) - .on_open_change(move |open, _, cx| { - if let Err(error) = weak_share.update(cx, |this, cx| { - this.share_open = open; - cx.notify(); - }) { - log::warn!("failed to update share menu: {error}"); - } - }) - .items(move |_, _| share_for_menu.menu()), - ) - .child( - DropdownMenu::new("repo-menu") - .icon(Icon::named(CustomIconName::EllipsisVertical)) - .variant(ButtonVariant::Secondary) - .loading(pushing) - .disabled(pushing) - .open(self.repo_menu_open) - .on_open_change(move |open, _, cx| { - if let Err(error) = weak_menu_open.update(cx, |this, cx| { - this.repo_menu_open = open; - cx.notify(); - }) { - log::warn!("failed to update repo menu: {error}"); - } - }) - .items(move |_, cx| { - let owner = Backend::global(cx) - .read(cx) - .current_user() - == Some(announcement_for_menu.owner); - - let mut items = vec![MenuItem::new("About").handler({ - let weak = weak_menu_items.clone(); - let announcement = announcement_for_menu.clone(); - move |_, cx| { - if let Err(error) = weak.update(cx, |this, cx| { - dialogs::about::open( - announcement.clone(), - this.directory.clone(), - cx, - ); - }) { - log::warn!("failed to open about: {error}"); - } - } - })]; - - if owner { - items.push(MenuItem::new("Republish").handler({ - let weak = weak_menu_items.clone(); - move |window, cx| { - if let Err(error) = weak.update(cx, |this, cx| { - this.push_repository(window, cx); - }) { - log::warn!("failed to republish: {error}"); - } - } - })); - items.push(MenuItem::new("Reset remote to local").handler({ - let weak = weak_menu_items.clone(); - move |window, cx| { - if let Err(error) = weak.update(cx, |this, cx| { - this.reset_remote(window, cx); - }) { - log::warn!("failed to reset remote: {error}"); - } - } - })); - items.push(MenuItem::separator()); - items.push( - MenuItem::new("Delete") - .destructive(true) - .handler({ - let weak = weak_menu_items.clone(); - move |window, cx| { - if let Err(error) = - weak.update(cx, |this, cx| { - this.delete_repository(window, cx); - }) - { - log::warn!( - "failed to delete repository: {error}" - ); - } - } - }), - ); - } - - 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, - )) - .child(clone_group( - "Clone with nak", - "copy-nak", - &nak_for_popover, - )) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .text_color( - cx.theme().colors.muted_foreground, - ) - .child("Grasp Servers"), - ) - .when(git_for_popover.is_empty(), |this| { - this.child( - div() - .text_xs() - .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)) - .into_any_element() - } - - fn render_local_header(&self, cx: &mut Context) -> AnyElement { - let name = repo_display_name(self.store.read(cx)); - let path = self - .store - .read(cx) - .path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(); - let avatar = Avatar::new(path.clone()); - - let bound = self.store.read(cx).nip34.clone(); - let action = match bound - .as_ref() - .filter(|binding| binding.kind == Nip34Kind::Initialized) - { - Some(binding) => bound_repo_label(binding, &self.directory, cx), - None => Button::new("init") - .icon(Icon::named(CustomIconName::Init)) - .label("Initialize on Nostr") - .tooltip(Tooltip::text("Publish this repository to Nostr")) - .on_click(cx.listener(|this, _, window, cx| { - this.open_init_dialog(window, cx); - })) - .into_any_element(), - }; - - v_flex() - .flex_none() - .px_4() - .pb_4() - .w_full() - .gap_6() - .border_b_1() - .border_color(cx.theme().colors.border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(avatar) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().colors.muted_foreground) - .child(path), - ), - ) - .child(action), - ) - .child(self.render_tabs(cx)) - .into_any_element() - } - - fn render_tabs(&self, cx: &mut Context) -> AnyElement { - let count = self.history.read(cx).commit_count(); - let muted = cx.theme().colors.muted_foreground; - let weak = cx.entity().downgrade(); - let commits_label = match count { - Some(count) => SharedString::from(format!("Commits ({count})")), - None => SharedString::from("Commits"), - }; - - Tabs::new("repo-tabs") - .selected(self.active_tab) - .tabs(move |_, _| { - vec![ - Tab::new("files", "Files") - .lead(Icon::named(CustomIconName::GitFile).color(muted)), - Tab::new("commits", commits_label.clone()) - .lead(Icon::named(CustomIconName::GitCommit).color(muted)), - ] - }) - .on_change(move |index, _, cx| { - if let Err(error) = weak.update(cx, |this, cx| { - this.active_tab = index; - cx.notify(); - }) { - log::warn!("failed to switch repo tab: {error}"); + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); + 'status: for status in statuses { + if self.banners.dismissal(&status) { + continue; + } + for pr in &store.pull_requests { + if CheckoutsStore::pr_proposes_checkout( + pr, + store.status_of(pr) == signed_core::RepoStatus::Open, + user, + &status, + ) { + continue 'status; } - }) - .into_any_element() + } + return Some(status); + } + None } - fn render_maintainers(&self, cx: &mut Context) -> AnyElement { - let Some(announcement) = self.announcement(cx) else { - return div().into_any_element(); + fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) }; - let mut seen = HashSet::new(); - let rest: Vec<_> = announcement - .maintainers - .iter() - .copied() - .filter(|key| key != &announcement.owner && seen.insert(*key)) - .collect(); - - let owner = self.directory.read(cx).profile(&announcement.owner, cx); - let owner_name = owner.name(); - - h_flex() - .w_full() - .gap_3() - .child( - h_flex() - .gap_2() - .child( - h_flex() - .gap_1() - .child( - Avatar::new(announcement.owner) - .name(owner_name.clone()) - .picture(owner.picture()) - .small(), - ) - .child(div().text_xs().whitespace_nowrap().child(owner_name)), - ) - .when(!rest.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .child(SharedString::from(format!("+{}", rest.len()))), + Some( + h_flex() + .p_4() + .w_full() + .justify_between() + .bg(cx.theme().colors.muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Badge::new("ready-branch", status.branch.clone())) + .child(commits) + .child("ahead of") + .child(Badge::new("ready-base", status.base.clone())), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("create-pr-from-banner") + .label("Create pull request") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.open_new_pull_request(window, cx); + })), ) - }), - ) - .into_any_element() + .child( + Button::new("dismiss-ready-banner") + .icon(Icon::named(IconRole::Close)) + .variant(ButtonVariant::Ghost) + .small() + .on_click(cx.listener(move |this, _, _, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) } fn push_suggestion(&self, cx: &App) -> Option { @@ -1758,7 +1198,8 @@ impl Panel for RepoDetailView { impl Render for RepoDetailView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let banner = self - .render_push_banner(cx) + .render_ready_banner(cx) + .or_else(|| self.render_push_banner(cx)) .or_else(|| self.render_sync_banner(cx)); let error = self.error.clone().or_else(|| { diff --git a/crates/signed_app/src/settings/helpers.rs b/crates/signed_app/src/settings/helpers.rs index bcedb41..2c7f681 100644 --- a/crates/signed_app/src/settings/helpers.rs +++ b/crates/signed_app/src/settings/helpers.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use gpui::{ AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, PathPromptOptions, - SharedString, Styled as _, WeakEntity, Window, div, px, + SharedString, Styled as _, Window, div, px, }; use gpui_native_kit::button::Button; use gpui_native_kit::common::{h_flex, v_flex}; @@ -13,7 +13,6 @@ use gpui_native_kit::theme::ActiveTheme as _; use gpui_native_kit::tooltip::Tooltip; use settings::{AppearanceMode, EventFetchingStrategy, SettingsStore}; -use super::SettingsDialog; use crate::repo::dialogs::grasp_servers::{normalize_server, server_host}; /// A settings row with the label and description on the left and the control on the right. @@ -306,10 +305,7 @@ fn add_scan_path(cx: &mut App) { .detach(); } -pub fn default_folder_selector( - default_folder: &Entity, - weak: WeakEntity, -) -> AnyElement { +pub fn default_folder_selector(default_folder: &Entity) -> AnyElement { let folder = default_folder.clone(); h_flex() .gap_1() @@ -324,21 +320,15 @@ pub fn default_folder_selector( .variant(gpui_native_kit::button::ButtonVariant::Ghost) .tooltip(Tooltip::text("Choose folder")) .on_click(move |_, window, cx| { - choose_default_folder(&folder, &weak, window, cx); + choose_default_folder(&folder, window, cx); }), ) .into_any_element() } -fn choose_default_folder( - default_folder: &Entity, - weak: &WeakEntity, - window: &mut Window, - cx: &mut App, -) { +fn choose_default_folder(default_folder: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let default_folder = default_folder.clone(); - let weak = weak.clone(); let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, directories: true, @@ -359,7 +349,6 @@ fn choose_default_folder( ); }); default_folder.update(cx, |input, cx| input.set_value(text, window, cx)); - let _ = weak.update(cx, |_this, cx| cx.notify()); }) { log::warn!("failed to apply default folder: {error}"); } diff --git a/crates/signed_app/src/settings/mod.rs b/crates/signed_app/src/settings/mod.rs index 348b985..c16d058 100644 --- a/crates/signed_app/src/settings/mod.rs +++ b/crates/signed_app/src/settings/mod.rs @@ -46,10 +46,9 @@ impl SettingsDialog { } impl Render for SettingsDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { let grasp_input = self.grasp_input.clone(); let default_folder = self.default_folder.clone(); - let weak = cx.entity().downgrade(); Dialog::new("settings-dialog") .open(true) @@ -95,7 +94,7 @@ impl Render for SettingsDialog { cx, "Default Folder", "The folder for newly created repositories.", - default_folder_selector(&default_folder, weak.clone()), + default_folder_selector(&default_folder), )) .into_any_element() }) diff --git a/crates/signed_app/src/shared/dialogs.rs b/crates/signed_app/src/shared/dialogs.rs index df78d3e..ebb7700 100644 --- a/crates/signed_app/src/shared/dialogs.rs +++ b/crates/signed_app/src/shared/dialogs.rs @@ -37,6 +37,15 @@ impl DialogHost { } } +impl Render for DialogHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .absolute() + .inset_0() + .when_some(self.open.clone(), |host, view| host.child(view)) + } +} + /// Replaces the mounted modal with `view`. pub fn open_global(view: impl Into, cx: &mut App) { let host = DialogHost::global(cx); @@ -48,12 +57,3 @@ pub fn close_global(cx: &mut App) { let host = DialogHost::global(cx); host.update(cx, |host, cx| host.close(cx)); } - -impl Render for DialogHost { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .absolute() - .inset_0() - .when_some(self.open.clone(), |host, view| host.child(view)) - } -} diff --git a/crates/signed_app/src/shared/mod.rs b/crates/signed_app/src/shared/mod.rs index 247c9e2..e8fb8a2 100644 --- a/crates/signed_app/src/shared/mod.rs +++ b/crates/signed_app/src/shared/mod.rs @@ -3,3 +3,16 @@ pub mod panels; pub mod progress; pub mod tree; pub mod widgets; + +/// Logs a discarded `Result` with its source location instead of dropping it silently. +#[track_caller] +pub fn log_dropped(result: Result) { + if let Err(error) = result { + let location = std::panic::Location::caller(); + log::warn!( + "{}:{}: dropped result: {error}", + location.file(), + location.line() + ); + } +} diff --git a/crates/signed_app/src/sidebar/mod.rs b/crates/signed_app/src/sidebar/mod.rs index 17918f4..4e1ca8d 100644 --- a/crates/signed_app/src/sidebar/mod.rs +++ b/crates/signed_app/src/sidebar/mod.rs @@ -34,6 +34,16 @@ use crate::settings; mod helpers; +#[derive(Clone, Copy)] +enum SidebarSlot { + Inbox, + Browse, + Search, + Header, + Repo(usize), + Local(usize), +} + pub struct SidebarView { weak: WeakEntity, dock: WeakEntity, @@ -333,6 +343,10 @@ impl SidebarView { ); } + debug_assert_eq!( + entries.len(), + slots(announcements.len(), local_repos.len()).len() + ); entries }) .on_change(move |index, window, cx| { @@ -346,39 +360,34 @@ impl SidebarView { let Some(index) = index else { return; }; - - match index { - 0 => { - self.open_inbox(window, cx); - return; - } - 1 | 2 => { - self.open_explore(window, cx); - return; - } - _ => {} - } - - let Some(row) = index.checked_sub(4) else { + let Some(slot) = slots(self.announcements.len(), self.local_repos.len()) + .get(index) + .copied() + else { return; }; - if row < self.announcements.len() { - let announcement = self.announcements[row].clone(); - let addr = announcement.addr(); - crate::repo::actions::open_repo_panel( - &self.dock, - &addr, - Some(&announcement), - window, - cx, - ); - return; - } - - let local_ix = row - self.announcements.len(); - if let Some(entry) = self.local_repos.get(local_ix).cloned() { - self.open_local_entry(entry, window, cx); + match slot { + SidebarSlot::Inbox => self.open_inbox(window, cx), + SidebarSlot::Browse | SidebarSlot::Search => self.open_explore(window, cx), + SidebarSlot::Header => {} + SidebarSlot::Repo(ix) => { + if let Some(announcement) = self.announcements.get(ix).cloned() { + let addr = announcement.addr(); + crate::repo::actions::open_repo_panel( + &self.dock, + &addr, + Some(&announcement), + window, + cx, + ); + } + } + SidebarSlot::Local(ix) => { + if let Some(entry) = self.local_repos.get(ix).cloned() { + self.open_local_entry(entry, window, cx); + } + } } } @@ -456,9 +465,9 @@ impl SidebarView { .label("Guide") .variant(ButtonVariant::Ghost) .on_click(move |_, window, cx| { - let _ = guide_weak.update(cx, |this, cx| { + crate::shared::log_dropped(guide_weak.update(cx, |this, cx| { this.open_explore(window, cx); - }); + })); }), ) .child( @@ -660,3 +669,15 @@ impl SidebarView { ) } } + +fn slots(announcements: usize, local_repos: usize) -> Vec { + let mut slots = vec![ + SidebarSlot::Inbox, + SidebarSlot::Browse, + SidebarSlot::Search, + SidebarSlot::Header, + ]; + slots.extend((0..announcements).map(SidebarSlot::Repo)); + slots.extend((0..local_repos).map(SidebarSlot::Local)); + slots +}