Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534d572154 | ||
|
|
4be75253cd | ||
|
|
2741ab6ac6 | ||
|
|
a051cb165e |
@@ -39,12 +39,7 @@ impl Assets {
|
||||
.filter_map(|path| {
|
||||
let data = Self::get(path.as_ref())?;
|
||||
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
||||
let content = match data.data {
|
||||
std::borrow::Cow::Borrowed(bytes) => {
|
||||
std::str::from_utf8(bytes).ok()?.to_owned()
|
||||
}
|
||||
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
|
||||
};
|
||||
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
|
||||
Some((name.to_owned(), content))
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -15,7 +15,7 @@ use gpui_base::dock::{
|
||||
};
|
||||
use gpui_base::resize_handle;
|
||||
use gpui_component::scroll::ScrollbarMode;
|
||||
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
|
||||
use gpui_component::{ActiveTheme as _, Side};
|
||||
|
||||
use crate::invalid_panel::InvalidPanel;
|
||||
use crate::tab_panel::SignedTabGroupSkin;
|
||||
@@ -155,27 +155,13 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let placement = dock.placement();
|
||||
let open = dock.is_open();
|
||||
|
||||
// A closed left or right dock takes no space.
|
||||
// A closed bottom dock keeps a strip so its tab bar stays clickable.
|
||||
if !open && !placement.is_bottom() {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.map(|this| match placement {
|
||||
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
|
||||
DockPlacement::Bottom => this.w_full().h(dock.size()),
|
||||
// Base never builds a dock for the centre.
|
||||
DockPlacement::Center => this,
|
||||
})
|
||||
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
|
||||
.when(!open && placement.is_bottom(), |this| {
|
||||
// A closed bottom dock keeps a strip, and that strip is the tab bar.
|
||||
.when(!dock.is_open() && placement.is_bottom(), |this| {
|
||||
this.h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
.child(content)
|
||||
|
||||
@@ -198,7 +198,7 @@ impl SignedTabGroupSkin {
|
||||
DockPlacement::Bottom => area
|
||||
.layout(DockPlacement::Bottom)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Center => None,
|
||||
DockPlacement::Center => return None,
|
||||
};
|
||||
if designated != Some(group.node()) {
|
||||
return None;
|
||||
|
||||
@@ -5,244 +5,3 @@ use nostr::prelude::*;
|
||||
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
||||
/// not part of the NIP-34 draft, read support for interop.
|
||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||
|
||||
/// Whether a kind-1985 label event is a valid annotation of `root`.
|
||||
///
|
||||
/// The event references the root with a lowercase `e` tag,
|
||||
/// its author must be the root author or a maintainer.
|
||||
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||
if event.kind != Kind::Label {
|
||||
return false;
|
||||
}
|
||||
if event.pubkey != root.pubkey && !maintainers.contains(&event.pubkey) {
|
||||
return false;
|
||||
}
|
||||
let root_id = root.id.to_hex();
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||
}
|
||||
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||
/// it must also carry at least one `["l", "<value>", "#t"]` label.
|
||||
fn has_hashtag_labels(event: &Event) -> bool {
|
||||
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective hashtag labels of `root`,
|
||||
/// the `t` tags on the event itself, self-reported by its author,
|
||||
/// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
|
||||
///
|
||||
/// Labels are additive, so all valid label events contribute,
|
||||
/// there is no latest-wins semantics.
|
||||
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||
let mut labels: Vec<String> = root
|
||||
.tags
|
||||
.hashtags()
|
||||
.map(|hashtag| hashtag.to_string())
|
||||
.collect();
|
||||
|
||||
for event in label_events {
|
||||
if !label_targets_root(event, root, maintainers) || !has_hashtag_labels(event) {
|
||||
continue;
|
||||
}
|
||||
for tag in event.tags.iter() {
|
||||
let slice = tag.as_slice();
|
||||
if slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty() {
|
||||
let label = &slice[1];
|
||||
if !labels.contains(label) {
|
||||
labels.push(label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
/// Subject or title override of `root` from authorized kind-1985 label events,
|
||||
/// only label events in the `#subject` namespace count.
|
||||
///
|
||||
/// Returns `None` when no valid override exists.
|
||||
pub fn subject_override(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> Option<String> {
|
||||
label_events
|
||||
.iter()
|
||||
.filter(|event| label_targets_root(event, root, maintainers))
|
||||
.filter(|event| {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.as_slice() == ["L", "#subject"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
slice.len() >= 3
|
||||
&& slice[0] == "l"
|
||||
&& slice[2] == "#subject"
|
||||
&& !slice[1].is_empty()
|
||||
})
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||
})
|
||||
.and_then(|event| {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
(slice.len() >= 3
|
||||
&& slice[0] == "l"
|
||||
&& slice[2] == "#subject"
|
||||
&& !slice[1].is_empty())
|
||||
.then(|| slice[1].clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective hashtag labels and subject override of `root` in one pass,
|
||||
/// mirrors ngit's `get_labels_and_subject`.
|
||||
pub fn labels_and_subject(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
(
|
||||
labels(root, label_events, maintainers),
|
||||
subject_override(root, label_events, maintainers),
|
||||
)
|
||||
}
|
||||
|
||||
/// Effective cover note of `root`.
|
||||
///
|
||||
/// Returns `None` when no valid cover note exists.
|
||||
pub fn cover_note<'a>(
|
||||
root: &Event,
|
||||
cover_notes: &'a [Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> Option<&'a Event> {
|
||||
let root_id = root.id.to_hex();
|
||||
|
||||
cover_notes
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& (event.pubkey == root.pubkey || maintainers.contains(&event.pubkey))
|
||||
&& event.tags.iter().any(|tag| {
|
||||
tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)
|
||||
})
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys_from_hex(hex: &str) -> Keys {
|
||||
Keys::new(SecretKey::from_hex(hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn root_event() -> Event {
|
||||
signed(
|
||||
&keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::hashtag("bug")],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_take_inline_hashtags_and_external_label_events() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let labels_event = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "help-wanted", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let labels = labels(&root, &[labels_event], &[maintainer.public_key()]);
|
||||
|
||||
assert_eq!(labels, vec!["bug", "help-wanted"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_override_latest_authorized_event_wins() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let older = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "Old title", "#subject"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let newer = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "New title", "#subject"]).expect("valid l tag"),
|
||||
],
|
||||
300,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
subject_override(&root, &[newer, older], &[maintainer.public_key()]),
|
||||
Some("New title".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_note_latest_authorized_event_wins() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let stranger =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||
let older = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 200);
|
||||
let newer = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 300);
|
||||
let unauthorized = signed(&stranger, COVER_NOTE_KIND, vec![e_tag(&root)], 400);
|
||||
|
||||
let newer_id = newer.id;
|
||||
let events = [older, unauthorized, newer];
|
||||
let maintainers = [maintainer.public_key()];
|
||||
let note = cover_note(&root, &events, &maintainers);
|
||||
assert_eq!(note.map(|event| event.id), Some(newer_id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Target of a `nostr://` clone URL, as defined by NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CloneTarget {
|
||||
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
/// `npub1...` or a NIP-05 identifier.
|
||||
user: String,
|
||||
relay_hint: Option<RelayUrl>,
|
||||
/// `d` tag identifier of the repository.
|
||||
identifier: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a `nostr://` clone URL. Returns `None` for other URL schemes.
|
||||
pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
let mut parts = rest.split('/');
|
||||
|
||||
let first = parts.next()?;
|
||||
let second = parts.next()?;
|
||||
let third = parts.next();
|
||||
|
||||
if first.starts_with("naddr1") {
|
||||
let coordinate = Nip19Coordinate::from_bech32(first).ok()?;
|
||||
return Some(CloneTarget::Addr(coordinate.coordinate));
|
||||
}
|
||||
|
||||
let (relay_hint, identifier) = match third {
|
||||
Some(id) => (
|
||||
RelayUrl::parse(&percent_decode(second)).ok(),
|
||||
percent_decode(id),
|
||||
),
|
||||
None => (None, percent_decode(second)),
|
||||
};
|
||||
|
||||
Some(CloneTarget::UserRepo {
|
||||
user: first.to_owned(),
|
||||
relay_hint,
|
||||
identifier,
|
||||
})
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = &input[i + 1..i + 3];
|
||||
if let Ok(v) = u8::from_str_radix(hex, 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_parts() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
|
||||
identifier: "my-local-only-repo".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,18 +91,6 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// Cover notes and NIP-32 label events referencing any of the given root events.
|
||||
/// These are kinds 1624 and 1985, matched via the `#e` tag.
|
||||
///
|
||||
/// Because they carry no repository `a` tag, they are fetched by root like comments.
|
||||
///
|
||||
/// Batched, like [`statuses_for`].
|
||||
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// A user's grasp list, kind `10317`.
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
|
||||
@@ -19,8 +19,6 @@ pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
/// The root event itself, when it is known locally.
|
||||
pub root_event: Option<Event>,
|
||||
/// Kind of the root event, when it is known locally.
|
||||
pub root_kind: Option<Kind>,
|
||||
/// Repository the root belongs to, from the root's `a` tag.
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
@@ -45,13 +43,11 @@ impl InboxItem {
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_kind.or_else(|| {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
})
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
/// Timestamp of the newest event in the thread.
|
||||
@@ -202,7 +198,6 @@ where
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next()),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod inbox;
|
||||
@@ -9,8 +8,7 @@ pub mod state;
|
||||
pub mod status;
|
||||
|
||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use annotations::COVER_NOTE_KIND;
|
||||
pub use deletions::Deletions;
|
||||
pub use filters::{
|
||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||
|
||||
@@ -41,12 +41,10 @@ pub struct Upstream {
|
||||
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
||||
/// `None` for the git-URL form.
|
||||
pub addr: Option<RepoAddr>,
|
||||
/// Relay hint for the upstream, if the `u` tag carries one.
|
||||
pub relay_hint: Option<RelayUrl>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
||||
fn parse(raw: &str) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
@@ -55,7 +53,6 @@ impl Upstream {
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +314,7 @@ impl Announcement {
|
||||
let values = tag.as_slice();
|
||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||
if !raw.is_empty() {
|
||||
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
|
||||
upstream = Some(Upstream::parse(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,10 +508,6 @@ mod tests {
|
||||
upstream.raw,
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.relay_hint,
|
||||
Some(RelayUrl::parse("wss://relay.example.com").expect("valid relay"))
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.display().to_string(),
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
|
||||
|
||||
@@ -72,17 +72,6 @@ fn file_commit_with_description(
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
|
||||
///
|
||||
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
|
||||
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||
let rel = rel.to_path_buf();
|
||||
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, commit)| commit))
|
||||
}
|
||||
|
||||
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||
/// `rels` are paths relative to the worktree.
|
||||
///
|
||||
|
||||
@@ -16,8 +16,8 @@ pub use diff::{
|
||||
worktree_commit_range_diff,
|
||||
};
|
||||
pub use history::{
|
||||
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit,
|
||||
worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
||||
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits,
|
||||
worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
||||
};
|
||||
pub use patch::{
|
||||
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::history::open_with_cache;
|
||||
use crate::worktree::{force_checkout, worktree_dirty};
|
||||
@@ -171,12 +171,7 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
|
||||
let mut index = repo.index_from_tree(&tree)?;
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
let commit = commit.to_string();
|
||||
if commit.len() != 40 {
|
||||
bail!("unexpected initial commit id: {commit}");
|
||||
}
|
||||
|
||||
Ok(commit)
|
||||
Ok(commit.to_string())
|
||||
}
|
||||
|
||||
/// The earliest unique commit of the repository at `repo_path`.
|
||||
@@ -201,8 +196,7 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
{
|
||||
let info = info?;
|
||||
if info.parent_ids().next().is_none() {
|
||||
let id = info.id().to_string();
|
||||
return Ok((id.len() == 40).then_some(id));
|
||||
return Ok(Some(info.id().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::*;
|
||||
@@ -578,7 +578,7 @@ fn git_run(dir: &Path, args: &[&str]) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_returns_most_recent_change() {
|
||||
fn worktree_last_commits_returns_most_recent_change() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
@@ -589,9 +589,12 @@ fn last_commit_returns_most_recent_change() {
|
||||
std::fs::write(dir.path().join("b.txt"), b"other").expect("write");
|
||||
commit_all(&repo, "add b");
|
||||
|
||||
let commit = last_commit(&repo, Path::new("a.txt"))
|
||||
let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")])
|
||||
.expect("lookup")
|
||||
.expect("found");
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("found")
|
||||
.1;
|
||||
assert_eq!(commit.summary, "change a");
|
||||
assert_eq!(commit.author, "Test Author");
|
||||
assert!(!commit.id.is_empty());
|
||||
@@ -621,7 +624,7 @@ fn all_commits_lists_every_commit() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_reports_merge_commits() {
|
||||
fn worktree_last_commits_reports_merge_commits() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"base")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
@@ -645,9 +648,12 @@ fn last_commit_reports_merge_commits() {
|
||||
// `--no-ff` forces a merge commit, it is the latest commit changing a.txt.
|
||||
run(&["merge", "--no-ff", "--no-edit", "feature"]);
|
||||
|
||||
let commit = last_commit(&repo, Path::new("a.txt"))
|
||||
let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")])
|
||||
.expect("lookup")
|
||||
.expect("found");
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("found")
|
||||
.1;
|
||||
assert_eq!(
|
||||
commit.id,
|
||||
repo.head_id().expect("head").shorten_or_id().to_string()
|
||||
|
||||
@@ -64,20 +64,10 @@ pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Resolve `rev` to a commit id, accepting full refs,
|
||||
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
|
||||
/// Resolve `rev` to a commit id, accepting full refs or the bare branch names
|
||||
/// callers pass. `gix`'s revision parser already applies git's ref DWIM.
|
||||
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
||||
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
// Branch names arrive bare, like git resolving `main`.
|
||||
if rev.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
|
||||
.ok()
|
||||
repo.rev_parse_single(rev.as_bytes()).ok()
|
||||
}
|
||||
|
||||
/// Relative paths of all entries in the worktree, files and directories.
|
||||
|
||||
@@ -5,32 +5,9 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nostr_connect::client::AuthUrlHandler;
|
||||
use nostr_sdk::error::Error as SignerError;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||
|
||||
impl fmt::Display for UniversalSignerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for UniversalSignerError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSignerError {
|
||||
pub fn new<E>(err: E) -> Self
|
||||
where
|
||||
E: Error + Send + Sync + 'static,
|
||||
{
|
||||
UniversalSignerError(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
@@ -65,21 +42,21 @@ impl UniversalSigner {
|
||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -94,22 +71,22 @@ where
|
||||
{
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,11 +94,11 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,17 +106,17 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
@@ -150,7 +127,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
@@ -162,7 +139,7 @@ impl AsyncSignEvent for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncNip44 for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
|
||||
@@ -1086,10 +1086,6 @@ impl Backend {
|
||||
self.signer.clone()
|
||||
}
|
||||
|
||||
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
|
||||
self.pushing_repos.clone()
|
||||
}
|
||||
|
||||
pub fn inbox(&self) -> Entity<Inbox> {
|
||||
self.inbox.clone()
|
||||
}
|
||||
@@ -1102,10 +1098,6 @@ impl Backend {
|
||||
self.passphrase_required
|
||||
}
|
||||
|
||||
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
||||
cx.emit(BackendEvent::error(message));
|
||||
}
|
||||
|
||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
let me = self.current_user;
|
||||
@@ -1498,24 +1490,21 @@ const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraspServerResult {
|
||||
pub relay: RelayUrl,
|
||||
pub git_url: String,
|
||||
/// `None` when the server accepted the data, the reason otherwise.
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl GraspServerResult {
|
||||
fn ok(relay: RelayUrl, git_url: String) -> Self {
|
||||
fn ok(relay: RelayUrl) -> Self {
|
||||
Self {
|
||||
relay,
|
||||
git_url,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(relay: RelayUrl, git_url: String, reason: impl Into<String>) -> Self {
|
||||
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
relay,
|
||||
git_url,
|
||||
reason: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
@@ -1715,17 +1704,11 @@ async fn push_staged_to_grasps(
|
||||
) -> PushOutcome {
|
||||
let mut outcome = PushOutcome::default();
|
||||
|
||||
if refs.is_empty() {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
for relay in servers {
|
||||
let Some(base) = grasp_base_url(relay) else {
|
||||
outcome.servers.push(GraspServerResult::failed(
|
||||
relay.clone(),
|
||||
relay.to_string(),
|
||||
"no domain",
|
||||
));
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServerResult::failed(relay.clone(), "no domain"));
|
||||
continue;
|
||||
};
|
||||
let git_url = format!("{base}/{owner}/{repo_id}.git");
|
||||
@@ -1806,11 +1789,9 @@ async fn push_staged_to_grasps(
|
||||
log::warn!("grasp push failed: {relay}: {reason}");
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServerResult::failed(relay.clone(), git_url, reason));
|
||||
.push(GraspServerResult::failed(relay.clone(), reason));
|
||||
}
|
||||
None => outcome
|
||||
.servers
|
||||
.push(GraspServerResult::ok(relay.clone(), git_url)),
|
||||
None => outcome.servers.push(GraspServerResult::ok(relay.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1968,13 +1949,9 @@ mod tests {
|
||||
fn push_outcome_reports_partial_failures() {
|
||||
let outcome = PushOutcome {
|
||||
servers: vec![
|
||||
GraspServerResult::ok(
|
||||
RelayUrl::parse("wss://gitnostr.com").expect("url"),
|
||||
"https://gitnostr.com/npub1owner/repo.git".to_owned(),
|
||||
),
|
||||
GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")),
|
||||
GraspServerResult::failed(
|
||||
RelayUrl::parse("wss://relay.ngit.dev").expect("url"),
|
||||
"https://relay.ngit.dev/npub1owner/repo.git".to_owned(),
|
||||
"remote: ERR authorisation failed: No state events in purgatory\nfatal: ...",
|
||||
),
|
||||
],
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Profile {
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key, 4))
|
||||
SharedString::from(shorten_pubkey(self.public_key))
|
||||
}
|
||||
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
|
||||
@@ -56,10 +56,6 @@ pub struct RepoStore {
|
||||
/// Computed with [`Self::status_by_root`] on every refresh.
|
||||
open_issue_count: usize,
|
||||
open_pr_count: usize,
|
||||
/// Incremented on every applied refresh.
|
||||
///
|
||||
/// Views key their derived-data caches to it instead of recomputing on every render.
|
||||
version: u64,
|
||||
pub last_error: Option<String>,
|
||||
/// Non-fatal warning of the last action, if any.
|
||||
///
|
||||
@@ -125,7 +121,6 @@ impl RepoStore {
|
||||
status_by_root: HashMap::new(),
|
||||
open_issue_count: 0,
|
||||
open_pr_count: 0,
|
||||
version: 0,
|
||||
last_error: None,
|
||||
last_warning: None,
|
||||
last_push_warning: None,
|
||||
@@ -153,7 +148,6 @@ impl RepoStore {
|
||||
status_by_root: HashMap::new(),
|
||||
open_issue_count: 0,
|
||||
open_pr_count: 0,
|
||||
version: 0,
|
||||
last_error: None,
|
||||
last_warning: None,
|
||||
last_push_warning: None,
|
||||
@@ -401,9 +395,6 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Cover notes, 1624, and label events, 1985, carry no `a` tag.
|
||||
// Query them per root like comments and statuses.
|
||||
// The events are only stored for interop and nothing displays them.
|
||||
sort_newest_first(&mut issues);
|
||||
sort_newest_first(&mut patches);
|
||||
sort_newest_first(&mut pull_requests);
|
||||
@@ -522,7 +513,6 @@ impl RepoStore {
|
||||
this.open_issue_count = open_issue_count;
|
||||
this.open_pr_count = open_pr_count;
|
||||
this.loaded = true;
|
||||
this.version = this.version.wrapping_add(1);
|
||||
|
||||
// Comments and statuses without an `a` tag.
|
||||
// None are addressed to the repository.
|
||||
@@ -580,10 +570,6 @@ impl RepoStore {
|
||||
status_of(&self.status_by_root, root)
|
||||
}
|
||||
|
||||
pub fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Number of open issues.
|
||||
///
|
||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||
|
||||
@@ -8,22 +8,18 @@ use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// A split dropdown button built on `gpui_base::Popover`.
|
||||
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
||||
/// The action and the caret are caller-supplied elements, so the look stays in the app.
|
||||
/// This component only owns the popover wiring.
|
||||
/// An action element next to a caret that opens a [`PopupMenu`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
caret: Option<CaretBuilder>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
||||
|
||||
impl DropdownButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
@@ -32,7 +28,6 @@ impl DropdownButton {
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
caret: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
@@ -54,14 +49,6 @@ impl DropdownButton {
|
||||
self.menu = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
|
||||
/// Which corner of the caret the menu anchors to.
|
||||
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
|
||||
#[allow(dead_code)] // API knob, current call sites use the default anchor.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for DropdownButton {
|
||||
@@ -91,28 +78,24 @@ impl RenderOnce for DropdownButton {
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
let caret = self.caret.unwrap_or_else(|| {
|
||||
let id = popover_id.clone();
|
||||
Box::new(move |is_open, _, cx| {
|
||||
let caret = default_caret(id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
let caret_id = popover_id.clone();
|
||||
this.child(
|
||||
Popover::new(popover_id)
|
||||
.anchor(anchor)
|
||||
// The menu dismisses itself on outside click or Escape.
|
||||
// The subscription below closes the popover along with it.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.trigger_with(move |is_open, _, cx| {
|
||||
let caret = default_caret(caret_id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||
pub fn shorten_pubkey(public_key: PublicKey) -> String {
|
||||
const HEAD_CHARS: usize = 9;
|
||||
const TAIL_CHARS: usize = 4;
|
||||
|
||||
let npub = public_key.to_bech32().unwrap();
|
||||
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
|
||||
format!(
|
||||
"{}...{}",
|
||||
&npub[..HEAD_CHARS],
|
||||
&npub[npub.len() - TAIL_CHARS..]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
@@ -21,7 +20,6 @@ use utils::relative_time;
|
||||
|
||||
use super::{RepoItem, open_repo_item};
|
||||
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
const LIST_OVERDRAW: Pixels = px(400.);
|
||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||
|
||||
@@ -196,10 +194,7 @@ impl InboxView {
|
||||
return;
|
||||
}
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
}));
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -408,7 +403,7 @@ impl InboxView {
|
||||
};
|
||||
|
||||
let root = item.root;
|
||||
let kind = item.root_kind;
|
||||
let kind = item.root_event.as_ref().map(|event| event.kind);
|
||||
let address = section.address.clone();
|
||||
let first = entry_ix == 0;
|
||||
let last = entry_ix + 1 == section.entries.len();
|
||||
|
||||
@@ -24,6 +24,7 @@ use utils::relative_time;
|
||||
pub(super) mod detail;
|
||||
|
||||
use self::detail::IssueDetailView;
|
||||
use super::status_list::{StatusCounts, filter_by_status};
|
||||
|
||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
@@ -52,7 +53,7 @@ pub struct IssuesView {
|
||||
filter: IssueFilter,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
visible_issues: Vec<usize>,
|
||||
counts: (usize, usize, usize),
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: IssueFilter,
|
||||
@@ -85,7 +86,7 @@ impl IssuesView {
|
||||
filter: IssueFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
visible_issues: Vec::new(),
|
||||
counts: (0, 0, 0),
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
@@ -97,25 +98,11 @@ impl IssuesView {
|
||||
|
||||
let (visible_issues, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize);
|
||||
|
||||
let visible_issues: Vec<usize> = store
|
||||
.issues
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, issue)| {
|
||||
let status = store.status_of(issue);
|
||||
counts.0 += 1;
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft | RepoStatus::Applied => {}
|
||||
}
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible_issues, counts)
|
||||
filter_by_status(
|
||||
&store.issues,
|
||||
|issue| store.status_of(issue),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
@@ -218,7 +205,7 @@ impl IssuesView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let (total, open, closed) = self.counts;
|
||||
let counts = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -234,7 +221,7 @@ impl IssuesView {
|
||||
.child(
|
||||
SegmentButton::new("all", "All")
|
||||
.icon(Icon::new(CustomIconName::GitIssueDone))
|
||||
.count(total)
|
||||
.count(counts.total)
|
||||
.selected(self.filter == IssueFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::All;
|
||||
@@ -244,7 +231,7 @@ impl IssuesView {
|
||||
.child(
|
||||
SegmentButton::new("open", "Open")
|
||||
.icon(Icon::new(CustomIconName::GitIssueOpen))
|
||||
.count(open)
|
||||
.count(counts.open)
|
||||
.selected(self.filter == IssueFilter::Open)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Open;
|
||||
@@ -254,7 +241,7 @@ impl IssuesView {
|
||||
.child(
|
||||
SegmentButton::new("closed", "Closed")
|
||||
.icon(Icon::new(CustomIconName::GitIssueClosed))
|
||||
.count(closed)
|
||||
.count(counts.closed)
|
||||
.selected(self.filter == IssueFilter::Closed)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Closed;
|
||||
|
||||
@@ -8,6 +8,7 @@ mod repo;
|
||||
mod repo_list;
|
||||
mod send_patch;
|
||||
pub(crate) mod sidebar;
|
||||
mod status_list;
|
||||
pub(crate) mod tree;
|
||||
|
||||
pub use inbox::InboxView;
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(super) mod new;
|
||||
use self::detail::PullRequestDetailView;
|
||||
use self::new::open_new_pull_panel;
|
||||
use super::send_patch::open_send_patch_panel;
|
||||
use super::status_list::{StatusCounts, filter_by_status};
|
||||
use crate::views::repo::RepoAction;
|
||||
|
||||
const ROW_HEIGHT: f32 = 73.;
|
||||
@@ -59,8 +60,7 @@ pub struct PullRequestsView {
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
||||
visible_prs: Vec<usize>,
|
||||
/// Header counts `(total, open, closed, draft, merged)`.
|
||||
counts: (usize, usize, usize, usize, usize),
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: PullRequestFilter,
|
||||
@@ -93,7 +93,7 @@ impl PullRequestsView {
|
||||
filter: PullRequestFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
visible_prs: Vec::new(),
|
||||
counts: (0, 0, 0, 0, 0),
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: PullRequestFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
@@ -105,32 +105,15 @@ impl PullRequestsView {
|
||||
|
||||
let (visible_prs, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
|
||||
let visible_prs: Vec<usize> = store
|
||||
let roots = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, pr)| {
|
||||
if pr.kind != Kind::GitPullRequest {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = store.status_of(pr);
|
||||
counts.0 += 1;
|
||||
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft => counts.3 += 1,
|
||||
RepoStatus::Applied => counts.4 += 1,
|
||||
}
|
||||
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible_prs, counts)
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest);
|
||||
filter_by_status(
|
||||
roots,
|
||||
|pr| store.status_of(pr),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
@@ -236,7 +219,7 @@ impl PullRequestsView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let (total, open, closed, draft, merged) = self.counts;
|
||||
let counts = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -252,7 +235,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
SegmentButton::new("all", "All")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||
.count(total)
|
||||
.count(counts.total)
|
||||
.selected(self.filter == PullRequestFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::All;
|
||||
@@ -262,7 +245,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
SegmentButton::new("open", "Open")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||
.count(open)
|
||||
.count(counts.open)
|
||||
.selected(self.filter == PullRequestFilter::Open)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Open;
|
||||
@@ -272,7 +255,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
SegmentButton::new("closed", "Closed")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
|
||||
.count(closed)
|
||||
.count(counts.closed)
|
||||
.selected(self.filter == PullRequestFilter::Closed)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Closed;
|
||||
@@ -282,7 +265,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
SegmentButton::new("draft", "Draft")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
|
||||
.count(draft)
|
||||
.count(counts.draft)
|
||||
.selected(self.filter == PullRequestFilter::Draft)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Draft;
|
||||
@@ -292,7 +275,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
SegmentButton::new("merged", "Merged")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
|
||||
.count(merged)
|
||||
.count(counts.applied)
|
||||
.selected(self.filter == PullRequestFilter::Merged)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Merged;
|
||||
|
||||
@@ -8,6 +8,8 @@ use nostr::prelude::*;
|
||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::{normalize_server, server_host};
|
||||
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct GraspServersState {
|
||||
@@ -155,7 +157,7 @@ fn render_server_row(
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(display_server(relay)),
|
||||
.child(server_host(relay)),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("remove-relay:{ix}"))
|
||||
@@ -174,14 +176,6 @@ fn render_server_row(
|
||||
)
|
||||
}
|
||||
|
||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
||||
fn display_server(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
@@ -194,14 +188,8 @@ fn add_relay(
|
||||
return;
|
||||
}
|
||||
|
||||
let normalized = if value.contains("://") {
|
||||
value.clone()
|
||||
} else {
|
||||
format!("wss://{value}")
|
||||
};
|
||||
|
||||
match RelayUrl::parse(&normalized) {
|
||||
Ok(relay) => {
|
||||
match normalize_server(&value) {
|
||||
Some((_, relay)) => {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = None;
|
||||
if !state.grasp_servers.contains(&relay) {
|
||||
@@ -210,7 +198,7 @@ fn add_relay(
|
||||
});
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
Err(_) => {
|
||||
None => {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ use gpui_base::Button as BaseButton;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::RelayUrl;
|
||||
use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||
@@ -542,6 +543,26 @@ impl SidebarPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
||||
///
|
||||
/// Returns the text to store and the parsed relay URL, or `None` when it is not a valid relay URL.
|
||||
pub(super) fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
|
||||
let text = if input.contains("://") {
|
||||
input.to_owned()
|
||||
} else {
|
||||
format!("wss://{input}")
|
||||
};
|
||||
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
|
||||
}
|
||||
|
||||
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
|
||||
pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
fn pick_banner() -> SharedString {
|
||||
let num = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -18,10 +18,11 @@ use gpui_component::{
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||
v_flex,
|
||||
};
|
||||
use nostr::prelude::RelayUrl;
|
||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
|
||||
use super::{normalize_server, server_host};
|
||||
|
||||
/// Looks up the option index used to seed a [`SelectState`].
|
||||
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
||||
options
|
||||
@@ -454,13 +455,11 @@ fn grasp_server_editor(
|
||||
}
|
||||
|
||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
||||
/// Matches how the publish dialogs display servers.
|
||||
fn display_server(server: &str) -> SharedString {
|
||||
RelayUrl::parse(server)
|
||||
.ok()
|
||||
.and_then(|relay| relay.domain().map(|domain| domain.to_owned()))
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(server.to_owned()))
|
||||
match normalize_server(server) {
|
||||
Some((_, relay)) => server_host(&relay),
|
||||
None => SharedString::from(server.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn repositories_section(
|
||||
@@ -568,14 +567,9 @@ fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let normalized = if value.contains("://") {
|
||||
value
|
||||
} else {
|
||||
format!("wss://{value}")
|
||||
};
|
||||
if RelayUrl::parse(&normalized).is_err() {
|
||||
let Some((normalized, _)) = normalize_server(&value) else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use nostr::prelude::Event;
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// Root events counted by their resolved status.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StatusCounts {
|
||||
pub(crate) total: usize,
|
||||
pub(crate) open: usize,
|
||||
pub(crate) closed: usize,
|
||||
pub(crate) draft: usize,
|
||||
pub(crate) applied: usize,
|
||||
}
|
||||
|
||||
impl StatusCounts {
|
||||
fn record(&mut self, status: RepoStatus) {
|
||||
self.total += 1;
|
||||
match status {
|
||||
RepoStatus::Open => self.open += 1,
|
||||
RepoStatus::Closed => self.closed += 1,
|
||||
RepoStatus::Draft => self.draft += 1,
|
||||
RepoStatus::Applied => self.applied += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of `roots` whose status `keep` accepts, counting every root's status.
|
||||
pub(crate) fn filter_by_status<'a>(
|
||||
roots: impl IntoIterator<Item = &'a Event>,
|
||||
status_of: impl Fn(&Event) -> RepoStatus,
|
||||
keep: impl Fn(RepoStatus) -> bool,
|
||||
) -> (Vec<usize>, StatusCounts) {
|
||||
let mut counts = StatusCounts::default();
|
||||
|
||||
let visible = roots
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, root)| {
|
||||
let status = status_of(root);
|
||||
counts.record(status);
|
||||
keep(status).then_some(index)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible, counts)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# TODO
|
||||
|
||||
## `BackendEvent::SyncProgress`
|
||||
|
||||
File: `crates/signed_state/src/backend.rs`
|
||||
|
||||
The variant has no subscriber. Remove the variant, the `sync_progress` field and
|
||||
its accessor, and the `progress_task` in `sync_bootstrap`. Keep the terminal
|
||||
`Synced` emission.
|
||||
|
||||
## `login` / `logout` family
|
||||
|
||||
File: `crates/signed_state/src/backend.rs`
|
||||
|
||||
No UI path calls these. `import_dialog::open` is an empty stub. Decide whether to
|
||||
delete the family (`login`, `login_with_new_identity`, `login_with_nsec`,
|
||||
`login_with_bunker`, `logout`) or wire the stub to `Backend::login`.
|
||||
Reference in New Issue
Block a user