8 Commits
Author SHA1 Message Date
reya 26653fa124 .
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-13 16:14:33 +07:00
reya 93d96ca9ee . 2026-09-13 15:54:17 +07:00
reya b59f6a95de . 2026-09-13 15:46:34 +07:00
reya 33e9429cd0 update 2026-09-13 15:17:51 +07:00
reya b3991810b6 clean up 2026-09-13 13:36:27 +07:00
reya f8277e4c2e refactor 3 2026-09-13 13:00:20 +07:00
reya 025fd15837 refactor 2 2026-09-13 12:20:49 +07:00
reya a4dcbbc560 refactor 2026-09-13 11:54:37 +07:00
82 changed files with 4151 additions and 8338 deletions
Generated
-1
View File
@@ -7968,7 +7968,6 @@ version = "0.1.0-alpha"
dependencies = [
"nostr",
"serde",
"serde_json",
]
[[package]]
-58
View File
@@ -111,61 +111,3 @@ impl IconNamed for CustomIconName {
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn signed_theme_set() -> gpui_component::ThemeSet {
let themes = Assets.themes();
assert_eq!(themes.len(), 1, "expected exactly one embedded theme file");
let (name, content) = &themes[0];
assert_eq!(name, "signed.json");
serde_json::from_str(content).expect("theme file must be a valid ThemeSet")
}
#[test]
fn signed_theme_set_parses() {
let set = signed_theme_set();
let names: Vec<&str> = set.themes.iter().map(|t| t.name.as_ref()).collect();
assert_eq!(names, vec!["Signed Light", "Signed Dark"]);
}
#[test]
fn signed_theme_palette_applies() {
let set = signed_theme_set();
let parse = |hex: &str| gpui_component::try_parse_color(hex).unwrap();
for config in &set.themes {
let mut theme = gpui_component::Theme::default();
theme.apply_config(&std::rc::Rc::new(config.clone()));
assert_eq!(theme.mode, config.mode);
// The resolved colors must match the brand palette.
assert_eq!(theme.primary, parse("#C6FF4D")); // nostr-lime
assert_eq!(theme.success, parse("#2FBF71")); // merge
assert_eq!(theme.primary_active, parse("#65A30D")); // lime-600
if config.mode.is_dark() {
// Dark theme chrome is neutral, mirroring the light theme.
assert_eq!(theme.background, parse("#0A0A0A")); // neutral-950
assert_eq!(theme.border, parse("#27272A")); // neutral-800
assert_eq!(theme.green, parse("#22C55E")); // green-500
} else {
// Light theme chrome is neutral, lime is a brand accent only.
assert_eq!(theme.background, parse("#FFFFFF"));
assert_eq!(theme.foreground, parse("#18181B"));
assert_eq!(theme.border, parse("#E4E4E7"));
assert_eq!(theme.green, parse("#16A34A"));
}
// Active tab, a paler lime on light and a dim moss on dark.
// Each is paired with readable contrasting text.
if config.mode.is_dark() {
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
} else {
assert_eq!(theme.tab_active, parse("#EBFFC1")); // pale nostr-lime
assert_eq!(theme.tab_active_foreground, parse("#3F6212")); // deep-lime
}
}
}
}
-1
View File
@@ -59,7 +59,6 @@ impl SignedTilesSkin {
}
}
/// One edge or corner handle.
fn resize_handle(
&self,
tile: &TileContext,
-1
View File
@@ -7,7 +7,6 @@ use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
use crate::TAB_BAR_HEIGHT;
/// The standard width of a window control button.
const CONTROL_WIDTH: f32 = 34.;
#[derive(IntoElement, Clone)]
-4
View File
@@ -23,7 +23,6 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// On Windows, this is `%APPDATA%\Signed`.
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Returns the current user's home directory.
pub fn home_dir() -> PathBuf {
dirs::home_dir().expect("failed to determine home directory")
}
@@ -42,7 +41,6 @@ pub fn documents_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
}
/// Returns the path to the configuration directory.
pub fn config_dir() -> &'static PathBuf {
CONFIG_DIR.get_or_init(|| {
if cfg!(target_os = "windows") {
@@ -62,7 +60,6 @@ pub fn config_dir() -> &'static PathBuf {
})
}
/// Returns the path to the data directory.
pub fn data_dir() -> &'static PathBuf {
CURRENT_DATA_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
@@ -98,7 +95,6 @@ pub fn repos_dir() -> &'static PathBuf {
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
}
/// Returns the path to the `settings.json` file.
pub fn settings_file() -> &'static PathBuf {
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
+2 -67
View File
@@ -2,29 +2,23 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// The default grasp servers,
/// offered while the user has not published a grasp list.
/// The default grasp servers, offered while the user has not published a grasp list.
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev",
"wss://gitnostr.com",
"wss://git.shakespeare.diy",
];
/// How the application picks its appearance.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppearanceMode {
/// Follow the system appearance, light or dark, at runtime.
#[default]
System,
/// Always use the light theme.
Light,
/// Always use the dark theme.
Dark,
}
/// Theme configuration,
/// fields mirror the gpui-component `Theme` surface customized at startup.
/// Fields mirror the gpui-component `Theme` surface customized at startup.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeSettings {
@@ -42,7 +36,6 @@ pub struct ThemeSettings {
pub radius_lg: f32,
/// Whether focused controls draw a ring outside their border.
pub focus_ring: bool,
/// Whether to render shadows.
pub shadow: bool,
}
@@ -61,7 +54,6 @@ impl Default for ThemeSettings {
}
}
/// Default grasp server settings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GraspServersSettings {
@@ -80,7 +72,6 @@ impl Default for GraspServersSettings {
}
}
/// Local repository scanning.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LocalReposSettings {
@@ -108,7 +99,6 @@ impl Default for LocalReposSettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CheckoutRecord {
/// Local folder of the checkout.
pub path: PathBuf,
/// Repository address as a string, `30617:<pubkey>:<id>`.
pub addr: String,
@@ -116,16 +106,13 @@ pub struct CheckoutRecord {
pub last_used: u64,
}
/// Remembered local checkouts, see [`CheckoutRecord`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CheckoutsSettings {
/// The remembered records.
/// The latest use of a path and repo pair replaces the older record.
pub records: Vec<CheckoutRecord>,
}
/// The create-repository dialog.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CreateRepositorySettings {
@@ -137,17 +124,11 @@ pub struct CreateRepositorySettings {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// How the application picks its appearance.
pub appearance: AppearanceMode,
/// Theme configuration.
pub theme: ThemeSettings,
/// Default grasp servers.
pub grasp_servers: GraspServersSettings,
/// Local repository scanning.
pub local_repos: LocalReposSettings,
/// Remembered local checkouts.
pub checkouts: CheckoutsSettings,
/// The create-repository dialog.
pub create_repository: CreateRepositorySettings,
}
@@ -155,30 +136,6 @@ pub struct Settings {
mod tests {
use super::*;
#[test]
fn defaults_match_the_app_conventions() {
let settings = Settings::default();
assert_eq!(settings.appearance, AppearanceMode::System);
assert_eq!(settings.theme.light_theme, "Signed Light");
assert_eq!(settings.theme.dark_theme, "Signed Dark");
assert_eq!(settings.theme.font_size, 16.0);
assert_eq!(settings.theme.mono_font_size, 13.0);
assert_eq!(settings.theme.radius, 2.0);
assert_eq!(settings.theme.radius_lg, 6.0);
assert!(!settings.theme.focus_ring);
assert!(!settings.theme.shadow);
assert_eq!(
settings.grasp_servers.default_servers,
DEFAULT_GRASP_SERVERS.map(String::from).to_vec()
);
assert_eq!(settings.local_repos.scan_paths.len(), 2);
assert_eq!(
settings.local_repos.scan_paths,
vec![paths::desktop_dir(), paths::documents_dir()]
);
assert_eq!(settings.create_repository.default_folder, None);
}
#[test]
fn json_roundtrip_preserves_everything() {
let settings = Settings {
@@ -198,12 +155,6 @@ mod tests {
assert_eq!(parsed, settings);
}
#[test]
fn missing_keys_fall_back_to_defaults() {
let settings: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(settings, Settings::default());
}
#[test]
fn partial_json_merges_with_defaults() {
let settings: Settings =
@@ -215,20 +166,4 @@ mod tests {
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
assert_eq!(settings.create_repository.default_folder, None);
}
#[test]
fn appearance_serializes_to_snake_case_names() {
assert_eq!(
serde_json::to_string(&AppearanceMode::System).unwrap(),
"\"system\""
);
assert_eq!(
serde_json::to_string(&AppearanceMode::Light).unwrap(),
"\"light\""
);
assert_eq!(
serde_json::to_string(&AppearanceMode::Dark).unwrap(),
"\"dark\""
);
}
}
-30
View File
@@ -24,7 +24,6 @@ impl SettingsStore {
cx.global::<GlobalSettingsStore>().0.clone()
}
/// Install the store as a global.
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalSettingsStore(entity));
}
@@ -103,8 +102,6 @@ impl SettingsStore {
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use gpui::{AppContext, TestAppContext};
use super::*;
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
@@ -133,16 +130,6 @@ mod tests {
cleanup(&path);
}
#[test]
fn corrupt_file_loads_defaults() {
let path = temp_settings_path();
std::fs::write(&path, "{ not json").unwrap();
let settings = SettingsStore::load(&path);
assert_eq!(settings, Settings::default());
cleanup(&path);
}
#[test]
fn save_and_load_roundtrip() {
let path = temp_settings_path();
@@ -160,21 +147,4 @@ mod tests {
assert_eq!(SettingsStore::load(&path), expected);
cleanup(&path);
}
#[gpui::test]
fn edit_mutates_and_persists(cx: &mut TestAppContext) {
let path = temp_settings_path();
cleanup(&path);
let store = cx.update(|cx| cx.new(|cx| SettingsStore::new(path.clone(), cx)));
cx.read(|cx| assert_eq!(store.read(cx).settings(), &Settings::default()));
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.radius = 12.0, cx);
});
cx.read(|cx| assert_eq!(store.read(cx).settings().theme.radius, 12.0));
assert_eq!(SettingsStore::load(&path).theme.radius, 12.0);
cleanup(&path);
}
}
-3
View File
@@ -7,6 +7,3 @@ publish.workspace = true
[dependencies]
nostr.workspace = true
serde.workspace = true
[dev-dependencies]
serde_json.workspace = true
-16
View File
@@ -6,12 +6,10 @@ use nostr::prelude::*;
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
pub type RepoAddr = Coordinate;
/// Build the address of a NIP-34 repository announcement.
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
}
/// Derive a repository identifier from a display name
pub fn identifier_from_name(name: &str) -> String {
name.chars()
.map(|c| {
@@ -23,17 +21,3 @@ pub fn identifier_from_name(name: &str) -> String {
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifier_from_name_slugs_like_gitworkshop() {
assert_eq!(identifier_from_name("My Repo"), "My-Repo");
assert_eq!(identifier_from_name("my-repo"), "my-repo");
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
assert_eq!(identifier_from_name("a/b"), "a/b");
assert_eq!(identifier_from_name("Café"), "Caf-");
}
}
-62
View File
@@ -196,61 +196,6 @@ mod tests {
assert_eq!(labels, vec!["bug", "help-wanted"]);
}
#[test]
fn labels_ignore_unauthorized_and_misnamed_events() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let stranger =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
// A stranger's label event is not authorized.
let stranger_labels = signed(
&stranger,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#t"]).expect("valid L tag"),
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
],
200,
);
// A valid author referencing a different event.
let other_labels = signed(
&maintainer,
Kind::Label,
vec![
Tag::parse([
"e",
"2222222222222222222222222222222222222222222222222222222222222222",
])
.expect("valid e tag"),
Tag::parse(["L", "#t"]).expect("valid L tag"),
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
],
200,
);
// A valid author without the namespace declaration.
let missing_namespace = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
],
200,
);
assert_eq!(
labels(
&root,
&[stranger_labels, other_labels, missing_namespace],
&[maintainer.public_key()]
),
vec!["bug"]
);
}
#[test]
fn subject_override_latest_authorized_event_wins() {
let root = root_event();
@@ -300,11 +245,4 @@ mod tests {
let note = cover_note(&root, &events, &maintainers);
assert_eq!(note.map(|event| event.id), Some(newer_id));
}
#[test]
fn cover_note_none_without_valid_events() {
let root = root_event();
assert_eq!(cover_note(&root, &[], &[]), None);
}
}
-29
View File
@@ -69,35 +69,6 @@ fn percent_decode(input: &str) -> String {
mod tests {
use super::*;
#[test]
fn parses_user_repo_without_relay() {
let target = parse_clone_url(
"nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit",
)
.unwrap();
assert_eq!(
target,
CloneTarget::UserRepo {
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(),
relay_hint: None,
identifier: "ngit".to_owned(),
}
);
}
#[test]
fn parses_user_repo_with_relay_hint() {
let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap();
assert_eq!(
target,
CloneTarget::UserRepo {
user: "danconwaydev.com".to_owned(),
relay_hint: RelayUrl::parse("relay.ngit.dev").ok(),
identifier: "ngit".to_owned(),
}
);
}
#[test]
fn decodes_percent_encoded_parts() {
let target = parse_clone_url(
-21
View File
@@ -253,13 +253,6 @@ mod tests {
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
}
#[test]
fn root_git_kinds_are_activity() {
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
}
}
#[test]
fn comment_activity_depends_on_the_uppercase_k_tag() {
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
@@ -307,18 +300,4 @@ mod tests {
Vec::new()
)));
}
#[test]
fn non_git_kinds_are_not_activity() {
assert!(!is_git_activity(&signed(
&keys(1),
Kind::TextNote,
Vec::new()
)));
assert!(!is_git_activity(&signed(
&keys(1),
Kind::GitPullRequestUpdate,
Vec::new(),
)));
}
}
-175
View File
@@ -44,7 +44,6 @@ impl InboxItem {
.unwrap_or_else(|| "Untitled".to_string())
}
/// Kind shown for the thread.
pub fn kind(&self) -> Option<Kind> {
self.root_kind.or_else(|| {
self.root_event
@@ -102,7 +101,6 @@ impl InboxItem {
!self.archived && !self.unread_ids.is_empty()
}
/// Recompute the unread and archived flags from `state`.
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
@@ -468,10 +466,6 @@ mod tests {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
}
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
@@ -489,18 +483,6 @@ mod tests {
)
}
#[test]
fn issue_and_pull_request_are_their_own_root() {
let events = [
issue(&keys(1), 100),
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
];
let lookup = lookup(&events);
for event in &events {
assert_eq!(notification_root(event, &lookup), Some(event.id));
}
}
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
@@ -520,17 +502,6 @@ mod tests {
);
}
#[test]
fn comment_without_root_pointer_has_no_root() {
let comment = signed(
&keys(2),
Kind::Comment,
vec![e_tag(&issue(&keys(1), 100))],
200,
);
assert_eq!(notification_root(&comment, &lookup(&[])), None);
}
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
@@ -555,19 +526,6 @@ mod tests {
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
}
#[test]
fn pull_request_update_resolves_via_uppercase_e() {
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
let update = signed(
&keys(2),
Kind::GitPullRequestUpdate,
vec![uppercase_e_tag(&pr)],
200,
);
let events = [pr.clone(), update.clone()];
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
}
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
@@ -577,96 +535,6 @@ mod tests {
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
}
#[test]
fn group_excludes_self_and_sorts_groups_newest_first() {
let me = keys(1);
let issue = issue(&keys(2), 100);
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
let other_issue = signed(
&keys(2),
Kind::GitIssue,
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
200,
);
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
let items = group(
events,
Vec::new(),
me.public_key(),
&InboxReadState::default(),
&lookup(&[]),
);
assert_eq!(items.len(), 2);
assert_eq!(items[0].root, issue.id);
// The issue itself plus the comment; the self-authored comment is out.
assert_eq!(items[0].events.len(), 2);
assert_eq!(items[1].root, other_issue.id);
}
#[test]
fn group_reports_unread_oldest_first_and_archived() {
let me = keys(1);
let issue = issue(&keys(2), 100);
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
let events = [issue.clone(), older.clone(), newer.clone()];
let items = group(
events,
Vec::new(),
me.public_key(),
&InboxReadState::default(),
&lookup(&[]),
);
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
assert!(!items[0].archived);
assert!(items[0].is_unread());
let state = InboxReadState {
archived_before: Timestamp::from_secs(1000),
..Default::default()
};
let items = group(
[issue.clone(), older, newer],
Vec::new(),
me.public_key(),
&state,
&lookup(&[]),
);
assert!(items[0].archived);
assert!(!items[0].unread_ids.is_empty());
assert!(!items[0].is_unread());
}
#[test]
fn group_reads_root_kind_and_address_from_the_root_event() {
let me = keys(1);
let owner_keys = keys(2);
let owner = owner_keys.public_key();
let issue = signed(
&owner_keys,
Kind::GitIssue,
vec![a_tag(&owner, "my-repo")],
100,
);
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let events = [issue.clone(), comment];
let items = group(
events.clone(),
Vec::new(),
me.public_key(),
&InboxReadState::default(),
&lookup(&events),
);
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
assert_eq!(items[0].address, issue.tags.coordinates().next());
}
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
@@ -809,47 +677,4 @@ mod tests {
state.mark_archived(&event);
assert_eq!(state.archived_ids, HashSet::from([event.id]));
}
#[test]
fn apply_state_recomputes_unread_and_archived() {
let now = Timestamp::from_secs(1_000_000_000);
let first = issue(&keys(2), now.as_secs() - 2000);
let second = issue(&keys(2), now.as_secs() - 1000);
let mut item = InboxItem {
root: first.id,
root_event: None,
root_kind: None,
address: None,
events: vec![second.clone(), first.clone()],
own_events: Vec::new(),
unread_ids: Vec::new(),
archived: false,
};
let state = InboxReadState {
read_before: first.created_at,
..Default::default()
};
item.apply_state(&state);
assert_eq!(item.unread_ids, vec![second.id]);
assert!(!item.archived);
}
#[test]
fn serde_round_trip_preserves_state() {
let first = issue(&keys(1), 100);
let second = issue(&keys(2), 200);
let state = InboxReadState {
read_before: Timestamp::from_secs(150),
read_ids: HashSet::from([second.id]),
archived_before: Timestamp::from_secs(50),
archived_ids: HashSet::from([first.id]),
};
let json = serde_json::to_string(&state).expect("serialized");
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
assert_eq!(parsed, state);
}
}
-133
View File
@@ -46,7 +46,6 @@ pub struct Upstream {
}
impl Upstream {
/// Parse the `u` tag values.
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
@@ -60,7 +59,6 @@ impl Upstream {
}
}
/// Text for display.
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
@@ -143,7 +141,6 @@ pub fn pull_request_patches<'a>(
series
}
/// The patch content of a pull request.
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied());
@@ -342,7 +339,6 @@ impl Announcement {
})
}
/// The repository address of this announcement.
pub fn addr(&self) -> RepoAddr {
repo_addr(self.owner, self.id.clone())
}
@@ -409,7 +405,6 @@ mod tests {
)
}
/// Build a signed kind `30617` event from raw tag values.
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
@@ -469,22 +464,6 @@ mod tests {
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
#[test]
fn requires_d_tag() {
let event = announcement_event(&[&["name", "No id"]]);
assert!(Announcement::from_event(&event).is_none());
}
#[test]
fn ignores_other_kinds() {
let event = EventBuilder::new(Kind::GitIssue, "")
.finalize(&keys())
.expect("signed event");
assert!(Announcement::from_event(&event).is_none());
}
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
@@ -505,17 +484,6 @@ mod tests {
assert!(announcement.maintainers.is_empty());
}
#[test]
fn ignores_unknown_tags() {
let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.id, "my-repo");
assert!(announcement.name.is_none());
assert!(announcement.web.is_empty());
}
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
@@ -553,25 +521,6 @@ mod tests {
);
}
#[test]
fn parses_git_url_upstream() {
// The `u` tag may reference a non-nostr upstream by git URL only.
// There is no repository address to navigate to.
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "https://example.com/upstream.git"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag");
assert_eq!(upstream.addr, None);
assert_eq!(
upstream.display().to_string(),
"https://example.com/upstream.git"
);
}
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
// The base repository, announced by the `u` tag's owner.
@@ -632,17 +581,6 @@ mod tests {
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
#[test]
fn is_fork_of_excludes_the_base_itself() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&event).expect("parses");
let base_addr = base.addr();
// The base announcement matches its own EUC but is not a fork of itself.
assert!(!base.is_fork_of(&base_addr, base.euc.as_deref()));
}
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
@@ -677,7 +615,6 @@ mod tests {
);
}
/// Build a signed PR event with the given tags and content.
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
@@ -685,35 +622,6 @@ mod tests {
.expect("signed event")
}
#[test]
fn pull_request_patch_prefers_linked_patch_event() {
let patch = EventBuilder::new(Kind::GitPatch, "patch-content")
.finalize(&keys())
.expect("signed event");
let pr = pr_event("description", vec![Tag::event(patch.id)]);
assert_eq!(pull_request_patch(&pr, [&patch]), "patch-content");
}
#[test]
fn pull_request_patch_falls_back_to_inline_content() {
// Older PRs carried the patch in the content and link no patch event.
let pr = pr_event("patch-inline", vec![]);
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
}
#[test]
fn pull_request_patch_ignores_unrelated_patch_events() {
let patch = EventBuilder::new(Kind::GitPatch, "patch-content")
.finalize(&keys())
.expect("signed event");
let pr = pr_event("description", vec![]);
assert_eq!(pull_request_patch(&pr, [&patch]), "description");
}
/// Build a signed patch event with a controlled `created_at`.
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
@@ -757,24 +665,6 @@ mod tests {
);
}
#[test]
fn pull_request_patches_ignores_unrelated_replies() {
let root = patch_event("patch-one", vec![], 100);
let other = patch_event("other-patch", vec![Tag::event(root.id)], 250);
// A patch replying to a different root is not part of the set.
let stranger = patch_event("stranger", vec![], 150);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = pull_request_patches(&pr, [&root, &other, &stranger]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "other-patch"]
);
}
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
// PRs without an `e` tag fall back to the patch producing the tip commit.
@@ -807,7 +697,6 @@ mod tests {
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
/// Build a signed event of `kind` with the given tags and `created_at`.
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
@@ -827,20 +716,6 @@ mod tests {
)
}
#[test]
fn reads_current_commit_and_branch_name() {
let pr = pr_root();
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
}
#[test]
fn returns_none_without_pr_tags() {
let pr = signed_at(Kind::GitPullRequest, vec![], 100);
assert_eq!(current_commit_of(&pr), None);
assert_eq!(branch_name_of(&pr), None);
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
@@ -886,19 +761,12 @@ mod tests {
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
}
#[test]
fn latest_update_ignores_roots_without_revisions() {
let root = pr_root();
assert!(latest_update([&root].into_iter(), &root).is_none());
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
/// Build a signed kind-30617 event for `owner` with the given tags.
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
@@ -960,7 +828,6 @@ mod tests {
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
// The user's fork comes first, then the other author's.
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
-34
View File
@@ -55,7 +55,6 @@ mod tests {
)
}
/// Build a signed kind `30618` event from raw tag values.
fn state_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
@@ -90,26 +89,6 @@ mod tests {
);
}
#[test]
fn head_without_prefix_is_ignored() {
let event = state_event(&[&["HEAD", "main"]]);
let (refs, head) = parse_state(&event);
assert!(refs.is_empty());
assert!(head.is_none());
}
#[test]
fn ignores_non_state_tags() {
let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]);
let (refs, head) = parse_state(&event);
assert!(refs.is_empty());
assert!(head.is_none());
}
#[test]
fn build_state_round_trips_through_parse() {
let refs = [
@@ -129,17 +108,4 @@ mod tests {
assert_eq!(parsed_refs, refs);
assert_eq!(head.as_deref(), Some("main"));
}
#[test]
fn build_state_omits_head_when_detached() {
let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())];
let event = build_state("my-repo", &refs, None)
.finalize(&keys())
.expect("signed event");
let (parsed_refs, head) = parse_state(&event);
assert_eq!(parsed_refs, refs);
assert!(head.is_none());
}
}
-62
View File
@@ -76,7 +76,6 @@ mod tests {
EventId::from_hex(ROOT_ID_HEX).expect("valid event id")
}
/// Build a signed status event with a controlled `created_at`.
fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags([Tag::event(root)])
@@ -102,53 +101,6 @@ mod tests {
));
}
#[test]
fn references_root_matches_uppercase_e_tag() {
let root = root_event_id();
let event = EventBuilder::new(Kind::Comment, "")
.tags([Tag::parse(["E", ROOT_ID_HEX]).expect("valid E tag")])
.finalize(&keys_from_hex(
"0000000000000000000000000000000000000000000000000000000000000001",
))
.expect("signed event");
assert!(references_root(&event, &root));
assert!(!references_root(
&event,
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
));
}
#[test]
fn references_root_false_without_e_tags() {
let event = EventBuilder::new(Kind::GitStatusOpen, "")
.finalize(&keys_from_hex(
"0000000000000000000000000000000000000000000000000000000000000001",
))
.expect("signed event");
assert!(!references_root(&event, &root_event_id()));
}
#[test]
fn defaults_to_open_without_status_events() {
let owner =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let statuses: Vec<Event> = Vec::new();
assert_eq!(
resolve_status(
statuses.iter(),
&owner.public_key(),
&[maintainer.public_key()]
),
RepoStatus::Open
);
}
#[test]
fn latest_status_wins() {
let owner =
@@ -196,18 +148,4 @@ mod tests {
RepoStatus::Draft
);
}
#[test]
fn ignores_non_status_kinds() {
let owner =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
let root = root_event_id();
let statuses = [status_event(&owner, Kind::GitIssue, root, 100)];
assert_eq!(
resolve_status(statuses.iter(), &owner.public_key(), &[]),
RepoStatus::Open
);
}
}
-6
View File
@@ -16,19 +16,16 @@ impl GitCache {
Self { root }
}
/// The root directory holding the mirror clones.
pub fn root(&self) -> &Path {
&self.root
}
/// Local path of the clone for a repository.
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
self.root
.join(addr.public_key.to_hex())
.join(sanitize_path_component(&addr.identifier))
}
/// Open an existing clone.
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
let path = self.repo_path(addr);
match gix::open(&path) {
@@ -64,9 +61,6 @@ impl GitCache {
}
/// Map an untrusted repository id or display name to a safe single path component.
///
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
/// An id that maps to exactly `.` or `..` becomes `_`.
pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id
.chars()
+1 -10
View File
@@ -3,18 +3,14 @@ use std::path::Path;
use anyhow::Result;
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
/// The kind of a [`DiffLine`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
/// An unchanged context line, present on both sides.
Context,
/// A line added by the commit.
Addition,
/// A line removed by the commit.
Deletion,
}
/// One line of a file diff.
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffLineKind,
@@ -31,16 +27,13 @@ pub struct DiffLine {
pub struct DiffHunk {
/// 1-based start line in the old version.
pub old_start: u32,
/// Number of old lines covered by the hunk.
pub old_lines: u32,
/// 1-based start line in the new version.
pub new_start: u32,
/// Number of new lines covered by the hunk.
pub new_lines: u32,
pub lines: Vec<DiffLine>,
}
/// How a file changed in a commit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
@@ -50,7 +43,6 @@ pub enum DiffStatus {
Copied,
}
/// The diff of one file in a commit.
#[derive(Debug, Clone)]
pub struct FileDiff {
/// Path of the file relative to the repo root.
@@ -69,7 +61,6 @@ pub struct FileDiff {
pub hunks: Vec<DiffHunk>,
}
/// The changes of one commit.
#[derive(Debug, Clone)]
pub struct CommitDiff {
pub files: Vec<FileDiff>,
@@ -110,7 +101,7 @@ pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Resu
.tree()?;
tree_diff(&repo, Some(&base_tree), &tip_tree)
}
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
fn tree_diff(
repo: &gix::Repository,
old_tree: Option<&gix::Tree<'_>>,
+1 -9
View File
@@ -20,7 +20,6 @@ pub struct FileCommit {
///
/// `None` for single-line commit messages.
pub description: Option<String>,
/// Author name.
pub author: String,
/// Author time, seconds since the Unix epoch.
pub time: i64,
@@ -36,7 +35,7 @@ pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
Ok(repo)
}
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
/// A [`FileCommit`] with author, message title, body and shortened id.
///
/// The diff panel fetches the full commit on demand.
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
@@ -50,7 +49,6 @@ fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
file_commit_with_description(commit, false)
}
/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body.
fn file_commit_with_description(
commit: &gix::Commit<'_>,
include_description: bool,
@@ -96,8 +94,6 @@ pub fn worktree_last_commits(
last_commits(&open_with_cache(workdir)?, rels)
}
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
///
/// Stops as soon as every pending path has its commit.
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
use gix::traverse::commit::simple::CommitTimeOrder;
@@ -106,7 +102,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
return Ok(Vec::new());
};
// De-duplicate while preserving order.
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
@@ -136,7 +131,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
};
// Compare each unresolved path against this commit and its first parent.
// Resolved paths leave the pending set.
let mut ix = 0;
while ix < pending.len() {
let rel = &pending[ix];
@@ -206,8 +200,6 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
Ok(CommitList { total, commits })
}
/// Like [`all_commits`], but opens the repository at `workdir` first.
///
/// For non-bare clones the clone root is the worktree.
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
all_commits(&open_with_cache(workdir)?)
-2
View File
@@ -38,8 +38,6 @@ pub use worktree::{
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
};
/// Run a git command in `dir`, returning trimmed stdout.
///
/// The terminal prompt is disabled so a credential request fails instead of hanging.
#[cfg(test)]
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
+4 -5
View File
@@ -9,10 +9,11 @@ use diffy::{Hunk, Line};
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
use crate::history::FileCommit;
/// Apply a `git format-patch` patch or series with `git am`,
/// uses the git CLI because it handles the mbox format natively.
/// Apply a `git format-patch` patch or series with `git am`.
///
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
/// Uses the git CLI because it handles the mbox format natively.
///
/// TODO: replace with a pure-Rust implementation later without changing callers.
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
let mut child = Command::new("git")
.arg("am")
@@ -116,7 +117,6 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
Ok(CommitDiff { files })
}
/// The [`FileDiff`] of one parsed file patch.
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
// component is dropped, the same way `git apply -p1` does.
@@ -296,7 +296,6 @@ pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
commits
}
/// The name part of a `From: Name <email>` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
-3
View File
@@ -41,7 +41,6 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
Ok(())
}
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
@@ -107,7 +106,6 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
Ok(repo)
}
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
push_refspecs(
repo_path,
@@ -131,7 +129,6 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
)
}
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
fn push_refspecs(
repo_path: &Path,
base_url: &str,
+1 -14
View File
@@ -55,7 +55,6 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>
};
let Some(base) = base else {
// `HEAD` alone when no base is given.
return Ok(vec![head.to_string()]);
};
@@ -168,7 +167,7 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
)?;
// Populate the index so the fresh repository is clean,
// as `git add` and`git commit` would leave it.
// as `git add` and `git commit` would leave it.
let mut index = repo.index_from_tree(&tree)?;
index.write(gix::index::write::Options::default())?;
@@ -190,7 +189,6 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
};
let Ok(head) = repo.head_id() else {
// An unborn HEAD with no commits yet has no root commit.
return Ok(None);
};
@@ -234,7 +232,6 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
}
}
// Sort lexicographically, like `git for-each-ref`.
names.sort();
Ok(names)
@@ -267,7 +264,6 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
})
.collect::<Result<Vec<_>>>()?;
// Delete all refs with the given prefix.
repo.edit_references(edits)?;
Ok(())
@@ -282,7 +278,6 @@ pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
Some(String::from_utf8_lossy(name.shorten()).into_owned())
}
/// Whether the reference `name` exists in the repository at `workdir`.
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
let Ok(repo) = gix::open(workdir) else {
return false;
@@ -374,10 +369,8 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
// Check out the remote tree, discarding local changes.
force_checkout(&repo, &tree)?;
// Update the branch reference to point to the remote tree.
repo.edit_references_as(
[edit(gix::refs::Target::Object(remote_oid))],
Some(signature),
@@ -385,7 +378,6 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
moved = true;
} else {
// Update the branch reference to point to the remote tree.
repo.edit_references_as(
[edit(gix::refs::Target::Object(remote_oid))],
Some(signature),
@@ -447,10 +439,6 @@ pub struct RepoRefState {
pub head: Option<String>,
}
/// Collect the refs of `repo`.
///
/// Local branches and tags become `(refname, commit-id)` pairs.
/// Also reports the branch HEAD points to.
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
let mut refs = Vec::new();
@@ -482,7 +470,6 @@ pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
Ok(RepoRefState { refs, head })
}
/// [`repo_ref_state`] for the repository at `workdir`.
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
repo_ref_state(&gix::open(workdir)?)
}
+3 -5
View File
@@ -2,13 +2,11 @@ use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
/// Maximum directory nesting depth when scanning for local repositories.
///
/// Pathological trees can't stall the scan.
/// Caps nesting so pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// Walk `root` recursively and collect the paths of git repositories below it,
/// honouring `.gitignore` (and `.ignore`) files.
/// Walk `root` recursively and collect the paths of git repositories below it.
/// `.gitignore` and `.ignore` files are honoured.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
if !root.is_dir() {
return Vec::new();
+3 -739
View File
@@ -1,25 +1,9 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::process::Command;
use nostr::prelude::*;
use signed_core::{Announcement, repo_addr};
use super::*;
#[test]
fn keeps_plain_ids() {
assert_eq!(sanitize_path_component("my-repo"), "my-repo");
assert_eq!(sanitize_path_component("repo.v2"), "repo.v2");
assert_eq!(sanitize_path_component("a_b-c"), "a_b-c");
}
#[test]
fn replaces_unsafe_characters() {
assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d");
assert_eq!(sanitize_path_component(""), "");
}
#[test]
fn blocks_parent_components() {
assert_eq!(sanitize_path_component(".."), "_");
@@ -29,34 +13,6 @@ fn blocks_parent_components() {
assert_eq!(sanitize_path_component("a/../b"), "a_.._b");
}
#[test]
fn fork_namespace_combines_owner_and_sanitized_id() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([Tag::parse(["d", "my/repo"]).expect("valid tag")])
.finalize(&keys)
.expect("signed event");
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(
fork_namespace(&announcement),
format!("{}/my_repo", keys.public_key().to_hex())
);
}
#[test]
fn repo_path_stays_inside_root() {
let cache = GitCache::new("/cache".into());
let owner = Keys::generate().public_key();
let path = cache.repo_path(&repo_addr(owner, ".."));
assert!(path.starts_with("/cache"));
assert_eq!(
path.file_name().map(|n| n.to_string_lossy().into_owned()),
Some("_".into())
);
}
#[test]
fn find_git_repos_discovers_repositories_recursively() {
let temp = tempfile::tempdir().unwrap();
@@ -74,18 +30,14 @@ fn find_git_repos_discovers_repositories_recursively() {
)
.unwrap();
// Plain directories are not repositories.
std::fs::create_dir_all(root.join("plain")).unwrap();
// A `.gitignore` at the root excludes dependency caches.
std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap();
// Hidden entries are skipped.
std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
// A repository is not descended into.
// Repositories inside it, like submodule worktrees, are not reported.
// A repository inside another, like a submodule worktree, is not reported.
let outer = root.join("outer");
std::fs::create_dir_all(outer.join(".git")).unwrap();
std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
@@ -120,13 +72,6 @@ fn root_commit_reports_the_first_ancestor() {
);
}
#[test]
fn root_commit_is_none_without_commits() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
let workdir = repo.workdir().expect("workdir");
assert_eq!(root_commit(workdir).expect("root"), None);
}
#[test]
fn push_all_mirrors_branches_and_tags() {
// A bare server repository reachable via a `file://` URL.
@@ -145,7 +90,6 @@ fn push_all_mirrors_branches_and_tags() {
commit_all(&repo, "initial");
let dir = dir.path();
// Two branches plus a tag are all mirrored.
git_run(dir, &["checkout", "-b", "feature"]);
std::fs::write(dir.join("b.txt"), b"two").expect("write");
commit_all(&repo, "feature work");
@@ -161,34 +105,6 @@ fn push_all_mirrors_branches_and_tags() {
assert!(refs.contains("refs/tags/v1.0"));
}
#[test]
fn push_all_tolerates_a_missing_ref_kind() {
// A repository with only tags and no branches still pushes.
// Wildcard refspecs without a local match are ignored.
let server = tempfile::tempdir().unwrap();
let server_repo = server.path().join("npub1test").join("my-repo.git");
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&server_repo)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
git_run(dir, &["tag", "v1.0"]);
git_run(dir, &["update-ref", "-d", "refs/heads/main"]);
let base_url = format!("file://{}", server.path().display());
push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
assert!(refs.contains("refs/tags/v1.0"));
assert!(!refs.contains("refs/heads/"));
}
#[test]
fn remote_has_refs_reports_whether_pushed_refs_landed() {
let server = tempfile::tempdir().unwrap();
@@ -208,7 +124,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
let expected = vec![("refs/heads/main".to_owned(), main.clone())];
// Nothing pushed yet: the ref is absent.
assert!(!remote_has_refs(dir, &url, &expected).expect("probe"));
push_all(
@@ -219,7 +134,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
)
.expect("push");
// The pushed ref is advertised at the expected commit.
assert!(remote_has_refs(dir, &url, &expected).expect("probe"));
// A stale expectation - the exact race a retry resolves - is false.
@@ -253,7 +167,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
assert_eq!(state.refs[0].0, format!("refs/heads/{branch}"));
assert_eq!(state.refs[0].1.len(), 40);
// Additional branches and tags are listed alongside.
git_run(&workdir, &["branch", "feature"]);
git_run(&workdir, &["tag", "v1.0"]);
@@ -273,7 +186,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
expected
);
// A detached HEAD yields no head branch.
git_run(&workdir, &["checkout", "--detach"]);
let state = repo_ref_state(&repo).expect("refs");
assert!(state.head.is_none());
@@ -294,52 +206,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) {
(dir, repo)
}
#[test]
fn worktree_entries_lists_all_files_and_dirs() {
let (_dir, repo) = fixture(&[
("README.md", b"# Hi"),
("src/main.rs", b"fn main() {}"),
("src/lib.rs", b""),
("docs/guide.md", b"guide"),
]);
let entries = worktree_entries(&repo).expect("entries");
let entries: Vec<String> = entries
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
entries,
vec![
"docs",
"src",
"README.md",
"docs/guide.md",
"src/lib.rs",
"src/main.rs"
]
);
}
#[test]
fn worktree_read_returns_bytes_or_none() {
let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]);
assert_eq!(
worktree_read(&repo, Path::new("a.txt")).expect("read"),
Some(b"hello".to_vec())
);
assert_eq!(
worktree_read(&repo, Path::new("sub/b.bin")).expect("read"),
Some(vec![0x00, 0x01])
);
assert_eq!(
worktree_read(&repo, Path::new("missing.txt")).expect("read"),
None
);
}
/// Stage everything and create a commit with the git CLI.
/// Like [`apply_patch`], the crate already shells out to the CLI.
fn commit_all(repo: &gix::Repository, message: &str) {
@@ -379,50 +245,6 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
assert!(merge_base(&path, "orphan", "no-such-ref").is_err());
}
#[test]
fn format_patch_between_produces_the_series_and_rejects_empty_ranges() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
git_run(&path, &["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "feature commit");
let patch = format_patch_between(&path, &initial, "feature").expect("patch");
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// An empty range has no commits to send.
assert!(format_patch_between(&path, "feature", "feature").is_err());
}
#[test]
fn push_commit_ref_pushes_to_the_event_namespace() {
// A bare server repository reachable via a `file://` URL.
// Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout.
let server = tempfile::tempdir().unwrap();
let server_repo = server.path().join("npub1test").join("my-repo.git");
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&server_repo)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip");
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push");
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
assert!(refs.contains("refs/nostr/abcd1234"));
}
#[test]
fn split_patch_series_splits_real_multi_commit_mboxes() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -448,14 +270,6 @@ fn split_patch_series_splits_real_multi_commit_mboxes() {
assert_ne!(first, second);
}
#[test]
fn split_patch_series_keeps_single_patches_whole() {
let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A <a@b>\nSubject: [PATCH] fix\n\n---\n";
let parts = split_patch_series(patch);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0], patch);
}
#[test]
fn head_commit_and_commits_since_track_applied_commits() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -466,7 +280,7 @@ fn head_commit_and_commits_since_track_applied_commits() {
head_commit_id(&path).expect("head").as_deref(),
Some(initial.as_str())
);
// No commits yet, `HEAD` alone.
// No base given, `HEAD` alone.
assert_eq!(
commits_since(&path, None).expect("commits"),
vec![initial.clone()]
@@ -491,24 +305,6 @@ fn head_commit_and_commits_since_track_applied_commits() {
);
}
#[test]
fn head_commit_reports_unborn_repositories() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let status = Command::new("git")
.args(["init", "-q"])
.arg(&path)
.status()
.expect("spawn git init");
assert!(status.success());
assert_eq!(head_commit_id(&path).expect("head"), None);
assert_eq!(
commits_since(&path, None).expect("commits"),
Vec::<String>::new()
);
}
#[test]
fn init_repository_creates_main_branch_and_readme() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -541,70 +337,12 @@ fn init_repository_creates_main_branch_and_readme() {
assert!(!worktree_dirty(workdir));
}
#[test]
fn init_repository_omits_description_when_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("my-repo");
init_repository(&path, "My Repo", " ").expect("init");
let repo = gix::open(&path).expect("open");
let workdir = repo.workdir().expect("workdir");
assert_eq!(
std::fs::read_to_string(workdir.join("README.md")).expect("read"),
"# My Repo\n"
);
}
#[test]
fn ensure_origin_adds_remote_only_once() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("my-repo");
init_repository(&path, "My Repo", "").expect("init");
ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
"https://gitnostr.com/npub1test/repo.git"
);
// The standard fetch mapping is configured with the remote.
// Later `git fetch origin` updates `refs/remotes/origin/*`.
assert_eq!(
git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"),
"+refs/heads/*:refs/remotes/origin/*"
);
// A second call must not override the existing remote.
ensure_origin(&path, "https://other.example/repo.git").expect("keep");
assert_eq!(
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
"https://gitnostr.com/npub1test/repo.git"
);
}
#[test]
fn origin_url_reads_the_remote_or_reports_none() {
let (dir, _repo) = fixture(&[("a.txt", b"one")]);
commit_all(&_repo, "initial");
let dir = dir.path();
// No remote configured yet.
assert_eq!(origin_url(dir).expect("read"), None);
ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
origin_url(dir).expect("read").as_deref(),
Some("https://gitnostr.com/npub1test/repo.git")
);
}
#[test]
fn set_origin_creates_or_replaces_the_remote() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("my-repo");
init_repository(&path, "My Repo", "").expect("init");
// No origin yet, so one is added.
set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add");
assert_eq!(
origin_url(&path).expect("url").as_deref(),
@@ -664,7 +402,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
.expect("spawn git init --bare");
assert!(init_status.success());
// The owner's working repo pushes the initial commit.
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
commit_all(&work_repo, "initial");
let work = work_dir.path();
@@ -746,7 +483,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
)
.expect("push");
// The base mirror is a plain clone of the base server.
let base_url = format!("file://{}", base_server.display());
let mirror = dir.path().join("mirror");
git_run(
@@ -792,7 +528,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
)
.expect("fetch");
// The imported refs are listed under the prefix only.
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
vec!["refs/fork/npub1fork/fork/feature"]
@@ -820,7 +555,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// Pruning the prefix removes the import again.
delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete");
assert_eq!(
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
@@ -828,30 +562,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
);
}
#[test]
fn fetch_repo_refs_fails_when_every_url_fails() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = _dir.path();
let dead = format!("file://{}/missing.git", dir.display());
let err =
fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*").expect_err("all URLs fail");
assert!(err.to_string().contains("failed to fetch"));
// Without any URL there is nothing to try.
let err =
fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
assert!(err.to_string().contains("no clone URLs"));
}
#[test]
fn delete_refs_with_prefix_is_a_noop_without_matches() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop");
}
/// Run a git command in `dir`, asserting success.
fn git_run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
@@ -910,25 +620,6 @@ fn all_commits_lists_every_commit() {
);
}
#[test]
fn all_commits_returns_empty_without_head() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
let list = all_commits(&repo).expect("commits");
assert!(list.commits.is_empty());
assert_eq!(list.total, 0);
}
#[test]
fn last_commit_returns_none_for_untracked_files() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write");
let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup");
assert!(commit.is_none());
}
#[test]
fn last_commit_reports_merge_commits() {
let (dir, repo) = fixture(&[("a.txt", b"base")]);
@@ -964,35 +655,6 @@ fn last_commit_reports_merge_commits() {
assert!(commit.summary.starts_with("Merge branch"));
}
#[test]
fn last_commits_batches_multiple_paths() {
let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]);
commit_all(&repo, "initial");
std::fs::write(dir.path().join("a.txt"), b"two").expect("write");
commit_all(&repo, "change a");
std::fs::write(dir.path().join("b.txt"), b"bb").expect("write");
commit_all(&repo, "change b");
let found = worktree_last_commits(
dir.path(),
&[
PathBuf::from("a.txt"),
PathBuf::from("b.txt"),
// Untracked paths are absent from the result.
PathBuf::from("missing.txt"),
],
)
.expect("commits");
let by_path: HashMap<&Path, &FileCommit> = found
.iter()
.map(|(path, commit)| (path.as_path(), commit))
.collect();
assert_eq!(by_path.len(), 2);
assert_eq!(by_path[Path::new("a.txt")].summary, "change a");
assert_eq!(by_path[Path::new("b.txt")].summary, "change b");
}
#[test]
fn find_readme_prefers_markdown() {
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
@@ -1004,63 +666,6 @@ fn find_readme_prefers_markdown() {
);
}
#[test]
fn find_readme_falls_back_to_any_readme() {
let (_dir, repo) = fixture(&[("README.rst", b"rst")]);
let readme = find_readme(&repo).expect("find");
assert_eq!(
readme.map(|p| p.to_string_lossy().into_owned()),
Some("README.rst".into())
);
}
#[test]
fn find_readme_returns_none_without_one() {
let (_dir, repo) = fixture(&[("main.rs", b"")]);
assert!(find_readme(&repo).expect("find").is_none());
}
#[test]
fn head_commit_reports_head() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
// Unborn HEAD means no commit yet.
assert!(head_commit(&repo).expect("head").is_none());
commit_all(&repo, "initial");
let head = head_commit(&repo).expect("head").expect("commit");
assert_eq!(
head.id,
repo.head_id().expect("head id").shorten_or_id().to_string()
);
assert_eq!(head.summary, "initial");
assert_eq!(head.author, "Test Author");
}
#[test]
fn worktree_branches_and_tags_list_short_names() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
git_run(dir, &["checkout", "-b", "feature"]);
git_run(dir, &["tag", "v0.9"]);
git_run(dir, &["tag", "v1.0"]);
// The initial branch name depends on git configuration.
// Only the branch we created is fixed.
let branches = worktree_branches(dir).expect("branches");
assert_eq!(branches.len(), 2);
assert!(branches.contains(&"feature".to_string()));
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
assert_eq!(
repo_tags(&repo).expect("tags"),
vec!["v0.9".to_string(), "v1.0".to_string()]
);
}
#[test]
fn current_branch_tracks_checkout() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
@@ -1083,12 +688,10 @@ fn current_branch_tracks_checkout() {
Some("feature")
);
// Tags detach HEAD.
git_run(dir, &["tag", "v1.0"]);
worktree_checkout_tag(dir, "v1.0").expect("checkout tag");
assert_eq!(current_branch(&repo).expect("branch"), None);
// Branches re-attach HEAD.
worktree_checkout_branch(dir, &default).expect("checkout branch");
assert_eq!(
current_branch(&repo).expect("branch").as_deref(),
@@ -1239,25 +842,6 @@ fn commit_range_diff_lists_changes_between_two_commits() {
assert!(diff.files.iter().all(|file| file.path != "b.txt"));
}
#[test]
fn commit_range_commits_lists_only_new_commits_newest_first() {
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
commit_all(&repo, "one");
let base = repo.head_id().expect("head").to_string();
std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write");
commit_all(&repo, "two");
std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write");
commit_all(&repo, "three");
let tip = repo.head_id().expect("head").to_string();
let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].summary, "three");
assert_eq!(commits[1].summary, "two");
}
#[test]
fn commit_diff_reports_binary_files_without_hunks() {
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
@@ -1279,56 +863,6 @@ fn commit_diff_reports_binary_files_without_hunks() {
assert_eq!(file.deletions, 0);
}
#[test]
fn commit_diff_resolves_short_ids_and_root_commit() {
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
commit_all(&repo, "initial");
// The root commit diffs against the empty tree, everything is added.
let head = repo.head_id().expect("head").shorten_or_id().to_string();
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
assert_eq!(diff.files.len(), 1);
assert_eq!(diff.files[0].path, "a.txt");
assert_eq!(diff.files[0].status, DiffStatus::Added);
assert_eq!(diff.files[0].insertions, 1);
}
#[test]
fn file_commit_includes_message_body() {
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "title");
// A single-line message has no body.
let head = head_commit(&repo).expect("head").expect("commit");
assert_eq!(head.summary, "title");
assert_eq!(head.description, None);
// A message with a body exposes it, trimmed.
let dir = _dir.path();
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args([
"commit",
"--allow-empty",
"-m",
"title two",
"-m",
"line one\n\nline two",
])
.status()
.expect("spawn git");
assert!(status.success(), "git commit failed");
let head = head_commit(&repo).expect("head").expect("commit");
assert_eq!(head.summary, "title two");
assert_eq!(head.description.as_deref(), Some("line one\n\nline two"));
}
#[test]
fn commit_diff_reports_renames() {
let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]);
@@ -1352,143 +886,6 @@ fn commit_diff_reports_renames() {
assert_eq!(file.deletions, 0);
}
#[test]
fn parses_format_patch_output() {
let patch = r#"From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [PATCH] fix
fix the thing
---
src/lib.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/lib.rs b/src/lib.rs
index 1234567..89abcde 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@
fn main() {
- println!("old");
+ println!("new");
}
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files.len(), 1);
let file = &diff.files[0];
assert_eq!(file.path, "src/lib.rs");
assert_eq!(file.old_path, None);
assert_eq!(file.status, DiffStatus::Modified);
assert_eq!(file.insertions, 1);
assert_eq!(file.deletions, 1);
let hunk = &file.hunks[0];
assert_eq!(hunk.old_start, 1);
assert_eq!(hunk.old_lines, 3);
assert_eq!(hunk.new_start, 1);
assert_eq!(hunk.new_lines, 3);
assert_eq!(hunk.lines.len(), 4);
assert_eq!(hunk.lines[0].kind, DiffLineKind::Context);
assert_eq!(hunk.lines[0].old, Some(1));
assert_eq!(hunk.lines[0].new, Some(1));
assert_eq!(hunk.lines[1].kind, DiffLineKind::Deletion);
assert_eq!(hunk.lines[1].old, Some(2));
assert_eq!(hunk.lines[1].new, None);
assert_eq!(hunk.lines[2].kind, DiffLineKind::Addition);
assert_eq!(hunk.lines[2].old, None);
assert_eq!(hunk.lines[2].new, Some(2));
assert_eq!(hunk.lines[3].kind, DiffLineKind::Context);
assert_eq!(hunk.lines[3].old, Some(3));
assert_eq!(hunk.lines[3].new, Some(3));
}
#[test]
fn parses_new_file_as_added() {
let patch = r#"diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1234567
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# hello
"#;
let diff = patch_diffs(patch).expect("parse");
let file = &diff.files[0];
assert_eq!(file.path, "README.md");
assert_eq!(file.status, DiffStatus::Added);
assert_eq!(file.old_path, None);
assert_eq!(file.insertions, 1);
assert_eq!(file.deletions, 0);
assert_eq!(file.hunks[0].old_start, 0);
assert_eq!(file.hunks[0].old_lines, 0);
assert_eq!(file.hunks[0].new_start, 1);
}
#[test]
fn parses_renames_with_old_path() {
let patch = r#"diff --git a/old.rs b/new.rs
similarity index 85%
rename from old.rs
rename to new.rs
index 123..456 100644
--- a/old.rs
+++ b/new.rs
@@ -1 +1 @@
-fn main() {}
+fn main() { println!("hi"); }
"#;
let diff = patch_diffs(patch).expect("parse");
let file = &diff.files[0];
assert_eq!(file.path, "new.rs");
assert_eq!(file.old_path.as_deref(), Some("old.rs"));
assert_eq!(file.status, DiffStatus::Renamed);
assert_eq!(file.insertions, 1);
assert_eq!(file.deletions, 1);
}
#[test]
fn parses_patch_series_and_skips_envelope() {
let patch = r#"From aaaa Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [PATCH 1/2] one
---
a.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/a.txt b/a.txt
index 1..2 100644
--- a/a.txt
+++ b/a.txt
@@ -1 +1,2 @@
a
+b
From bbbb Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [PATCH 2/2] two
diff --git a/b.txt b/b.txt
index 3..4 100644
--- a/b.txt
+++ b/b.txt
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files.len(), 2);
assert_eq!(diff.files[0].path, "a.txt");
assert_eq!(diff.files[0].insertions, 1);
assert_eq!(diff.files[1].path, "b.txt");
assert_eq!(diff.files[1].deletions, 1);
}
#[test]
fn patch_commits_lists_every_patch_in_order() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
@@ -1536,119 +933,6 @@ diff --git a/b.txt b/b.txt
assert_eq!(commits[1].time, 1690975800);
}
#[test]
fn patch_commits_strips_patch_subject_prefixes() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [RFC PATCH v3 4/7] the real title
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "the real title");
}
#[test]
fn patch_commits_handles_missing_headers() {
// A hand-written patch without author or date headers still lists a commit.
// Time stays 0 and the author stays empty.
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
Subject: [PATCH] plain
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "plain");
assert_eq!(commits[0].author, "");
assert_eq!(commits[0].time, 0);
}
#[test]
fn patch_commits_ignores_non_patch_lines() {
assert!(patch_commits("").is_empty());
assert!(patch_commits("just some text\nFrom 123\n").is_empty());
// A diff-only body without an mbox envelope has no commits.
let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n";
assert!(patch_commits(patch).is_empty());
}
#[test]
fn marks_binary_sections() {
let patch = r#"diff --git a/img.png b/img.png
index 123..456 100644
Binary files a/img.png and b/img.png differ
"#;
let diff = patch_diffs(patch).expect("parse");
assert!(diff.files[0].binary);
assert!(diff.files[0].hunks.is_empty());
}
#[test]
fn unquotes_quoted_paths() {
let patch = r#"diff --git "a/weird file.rs" "b/weird file.rs"
index 123..456 100644
--- "a/weird file.rs"
+++ "b/weird file.rs"
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files[0].path, "weird file.rs");
assert_eq!(diff.files[0].status, DiffStatus::Modified);
}
#[test]
fn unquotes_non_ascii_quoted_paths() {
let patch = r#"diff --git "a/说明.md" "b/说明.md"
index 123..456 100644
--- "a/说明.md"
+++ "b/说明.md"
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files[0].path, "说明.md");
assert_eq!(diff.files[0].status, DiffStatus::Modified);
}
#[test]
fn unquotes_octal_escaped_paths() {
let patch = r#"diff --git "a/\345\270\226.md" "b/\345\270\226.md"
index 123..456 100644
--- "a/\345\270\226.md"
+++ "b/\345\270\226.md"
@@ -1 +1 @@
-x
+y
"#;
let diff = patch_diffs(patch).expect("parse");
assert_eq!(diff.files[0].path, "帖.md");
assert_eq!(diff.files[0].status, DiffStatus::Modified);
}
#[test]
fn empty_or_unparseable_patch_yields_no_files() {
assert_eq!(patch_diffs("").expect("parse").files.len(), 0);
assert_eq!(patch_diffs("just some text").expect("parse").files.len(), 0);
assert_eq!(
patch_diffs("---\nnot a patch\n")
.expect("parse")
.files
.len(),
0
);
}
#[test]
fn parses_real_format_patch_output() {
// Build a commit touching a mix of file kinds.
@@ -1735,7 +1019,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
assert!(!worktree_dirty(workdir));
// A modified tracked file is dirty.
std::fs::write(workdir.join("tracked.txt"), b"two").expect("write");
assert!(worktree_dirty(workdir));
@@ -1745,7 +1028,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
std::fs::write(workdir.join("untracked.txt"), b"new").expect("write");
assert!(worktree_dirty(workdir));
// A staged change counts too.
git_run(workdir, &["rm", "--cached", "tracked.txt"]);
assert!(worktree_dirty(workdir));
@@ -1753,24 +1035,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
assert!(!worktree_dirty(&dir.path().join("missing")));
}
#[test]
fn worktree_dirty_reports_unborn_worktrees_with_files() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let status = Command::new("git")
.args(["init", "-q"])
.arg(&path)
.status()
.expect("spawn git init");
assert!(status.success());
// No commits and no files: porcelain is empty.
assert!(!worktree_dirty(&path));
// An unborn repository holding files is dirty.
std::fs::write(path.join("README.md"), "# hello\n").expect("write");
assert!(worktree_dirty(&path));
}
#[test]
fn worktree_commits_ahead_counts_branch_only_commits() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
-10
View File
@@ -164,8 +164,6 @@ pub struct WorktreeSnapshot {
pub head_commit: Option<FileCommit>,
}
/// Snapshot the worktree after a branch or tag switch.
///
/// Collects entries, the README, the branch HEAD points to and its commit.
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
let repo = gix::open(workdir)?;
@@ -183,7 +181,6 @@ pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
})
}
/// Check out `tree` into the worktree of `repo`
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
let workdir = repo
.workdir()
@@ -229,7 +226,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R
let files = gix::progress::Discard;
let bytes = gix::progress::Discard;
// Check out the index into the worktree.
gix_worktree_state::checkout(
&mut index,
workdir,
@@ -240,7 +236,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R
options,
)?;
// Write the index to disk.
index.write(gix::index::write::Options::default())?;
Ok(())
@@ -258,7 +253,6 @@ fn move_head(
let head = gix::refs::FullName::try_from("HEAD")
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
// Update the reference, creating a reflog entry.
repo.edit_references_as(
[RefEdit {
change: Change::Update {
@@ -293,7 +287,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
let (signature, mut time_buf) = repository_signature();
let signature = signature.to_ref(&mut time_buf);
// Move HEAD to the branch, creating a reflog entry.
move_head(
&repo,
signature,
@@ -301,7 +294,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
&format!("checkout: moving to {name}"),
)?;
// Check out the branch's tree, replacing index + worktree.
force_checkout(&repo, &tree)?;
Ok(())
@@ -320,7 +312,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
let (signature, mut time_buf) = repository_signature();
let signature = signature.to_ref(&mut time_buf);
// Move HEAD to the tag, creating a reflog entry.
move_head(
&repo,
signature,
@@ -328,7 +319,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
&format!("checkout: moving to {name}"),
)?;
// Check out the tag's tree, replacing index + worktree.
force_checkout(&repo, &tree)?;
Ok(())
-1
View File
@@ -10,7 +10,6 @@ pub struct Update {
}
impl Update {
/// Build an update from a received event.
pub fn from_event(event: &Event) -> Self {
let coordinate = event.tags.coordinates().nth(0);
-152
View File
@@ -17,7 +17,6 @@ use crate::git_store::GitStore;
use crate::inbox::Inbox;
use crate::repos::RepoListStore;
/// Keyring entry for the user credential.
pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -33,12 +32,10 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
/// Relays used to index the user's NIP-65 relay list.
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// Delay the notification pump waits for more events before emitting a batch.
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
SignerRequired,
/// The stored identity is NIP-49 encrypted key.
PassphraseRequired,
@@ -51,18 +48,13 @@ pub enum BackendEvent {
/// instead of emitting per-event and making every subscriber debounce
/// the same burst independently.
NostrUpdate(Vec<Update>),
/// A negentropy sync completed.
Synced,
/// A negentropy sync is in flight.
SyncProgress {
/// Total events to process.
total: u64,
/// Events processed so far.
current: u64,
},
/// An event built locally was signed, broadcast and stored.
Published(Box<Event>),
/// An error occurred.
Error(String),
}
@@ -79,13 +71,10 @@ pub struct Backend {
client: Client,
signer: UniversalSigner,
current_user: Option<PublicKey>,
/// User's inbox, including notifications and recent activity.
inbox: Entity<Inbox>,
/// The progress of the current sync operation, if any.
sync_progress: Option<(u64, u64)>,
/// True when the stored credential is NIP-49 encrypted.
passphrase_required: bool,
/// Repositories with a push in flight, mirror or checkout based.
pushing_repos: Entity<HashSet<RepoAddr>>,
}
@@ -96,7 +85,6 @@ impl Global for GlobalBackend {}
impl EventEmitter<BackendEvent> for Backend {}
impl Backend {
/// Retrieve the global backend.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalBackend>().0.clone()
}
@@ -114,7 +102,6 @@ impl Backend {
let mut pending: Vec<Update> = Vec::new();
'outer: loop {
// Wait for the first event of a batch.
match notifications.next().await {
Some(ClientNotification::Event { event, .. }) => {
pending.push(Update::from_event(&event));
@@ -123,7 +110,6 @@ impl Backend {
None => break,
}
// Collect everything else that arrives within the debounce window.
let deadline = Instant::now() + PUMP_DEBOUNCE;
loop {
@@ -152,7 +138,6 @@ impl Backend {
}
}
// Collect and emit the collected events.
let batch = std::mem::take(&mut pending);
if let Err(e) =
@@ -167,7 +152,6 @@ impl Backend {
pump.detach();
// Bootstrap the client.
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
log::warn!("backend dropped before bootstrap could run: {error}");
@@ -185,7 +169,6 @@ impl Backend {
}
}
/// Bootstrap the client and restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -259,7 +242,6 @@ impl Backend {
signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") {
// A passphrase is required to decrypt it before the session can resume.
this.update(cx, |this, cx| {
this.passphrase_required = true;
cx.emit(BackendEvent::PassphraseRequired);
@@ -319,7 +301,6 @@ impl Backend {
})
}
/// Create a new identity.
pub fn create_identity(
&mut self,
name: &str,
@@ -348,7 +329,6 @@ impl Backend {
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
// Persist the encrypted credential.
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
});
@@ -446,7 +426,6 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to create a repository")));
};
// The repository identifier is derived from the name.
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
@@ -629,7 +608,6 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
};
// The identifier derives from the name, as in [`Self::create_repository`].
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
@@ -937,7 +915,6 @@ impl Backend {
let addr = addr.clone();
cx.spawn(async move |this, cx| {
// Collect every event of the repository from the local database.
let events = cx.background_spawn(async move {
let db = client.database();
let mut events = Vec::new();
@@ -974,7 +951,6 @@ impl Backend {
}
}
/// Create a fresh identity and login with it.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate()
.secret_key()
@@ -983,7 +959,6 @@ impl Backend {
self.login_with_nsec(&nsec, cx);
}
/// Login with an `nsec1...` secret key.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret),
@@ -1053,7 +1028,6 @@ impl Backend {
task.detach();
}
/// Remove the saved credential and reset to an anonymous session.
pub fn logout(&mut self, cx: &mut Context<Self>) {
let delete = cx.delete_credentials(USER_KEYRING);
@@ -1075,7 +1049,6 @@ impl Backend {
task.detach();
}
/// Sync the user's grasp list and add the listed grasp servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1105,46 +1078,34 @@ impl Backend {
task.detach();
}
/// Get the nostr client.
pub fn client(&self) -> Client {
self.client.clone()
}
/// Get the current signer.
pub fn signer(&self) -> UniversalSigner {
self.signer.clone()
}
/// Repositories with a push in flight, mirror or checkout based.
///
/// A child entity: `cx.observe` it to react only to push-state changes.
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
self.pushing_repos.clone()
}
/// The inbox child entity backing the home screen.
///
/// A child entity: `cx.observe` it to react only to inbox changes.
pub fn inbox(&self) -> Entity<Inbox> {
self.inbox.clone()
}
/// Get the current user's public key.
pub fn current_user(&self) -> Option<PublicKey> {
self.current_user
}
/// True when the stored credential is NIP-49 encrypted.
pub fn passphrase_required(&self) -> bool {
self.passphrase_required
}
/// Surface an error message through [`BackendEvent::Error`].
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
cx.emit(BackendEvent::error(message));
}
/// Attach the inbox to the current signer and activate or clear it.
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
let me = self.current_user;
@@ -1173,12 +1134,10 @@ impl Backend {
});
}
/// Progress of the in-flight negentropy sync, if any.
pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress
}
/// Update the signer.
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
@@ -1229,7 +1188,6 @@ impl Backend {
.detach();
}
/// One-shot subscription on the bootstrap relays only.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1247,7 +1205,6 @@ impl Backend {
.detach();
}
/// Negentropy-sync the given filter against the bootstrap relays.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone();
let (tx, mut rx) = SyncProgress::channel();
@@ -1399,7 +1356,6 @@ async fn publish_best_effort(client: &Client, signer: &UniversalSigner, builder:
}
}
/// Add the given relays, connect and fetch the filters.
async fn connect_repo_relays(
client: &Client,
relays: Vec<RelayUrl>,
@@ -1409,12 +1365,10 @@ async fn connect_repo_relays(
return Ok(());
}
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await?;
}
// Run neg sync for each filter
for filter in filters.into_iter() {
if let Err(e) = client.sync(filter).with(relays.iter()).await {
log::warn!("repo relay negentropy sync failed: {e}");
@@ -1424,7 +1378,6 @@ async fn connect_repo_relays(
Ok(())
}
/// Subscribe only on the bootstrap relays.
pub(crate) async fn subscribe_bootstrap_only(
client: &Client,
filters: Vec<Filter>,
@@ -1443,7 +1396,6 @@ pub(crate) async fn subscribe_bootstrap_only(
Ok(())
}
/// Negentropy-sync the filter against the bootstrap relays only.
pub(crate) async fn sync_bootstrap_only(
client: &Client,
filter: Filter,
@@ -1481,7 +1433,6 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
Some(format!("{scheme}://{host}{port}"))
}
/// GRASP clone URL of a repository on a grasp server.
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
let base = grasp_base_url(relay)?;
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
@@ -1526,7 +1477,6 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
.unwrap_or_default()
}
/// Resolve the user's published grasp servers from the local database.
pub async fn user_grasp_list_servers(
client: Client,
user: PublicKey,
@@ -1540,18 +1490,14 @@ pub async fn user_grasp_list_servers(
Ok(latest_grasp_list_servers(events))
}
/// Attempts per grasp server when a git push is denied transiently.
const GRASP_PUSH_ATTEMPTS: usize = 3;
/// Pause before re-staging a state event after a transient denial.
const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
/// The outcome of pushing to one grasp server.
#[derive(Debug, Clone)]
pub struct GraspServerResult {
/// The grasp server's relay URL, e.g. `wss://relay.ngit.dev`.
pub relay: RelayUrl,
/// The git URL the data was pushed to.
pub git_url: String,
/// `None` when the server accepted the data, the reason otherwise.
pub reason: Option<String>,
@@ -1575,7 +1521,6 @@ impl GraspServerResult {
}
}
/// The outcome of a staged push across every grasp server of a repository.
#[derive(Debug, Clone, Default)]
pub struct PushOutcome {
/// Per-server results, in the order the servers were listed.
@@ -1587,7 +1532,6 @@ pub struct PushOutcome {
}
impl PushOutcome {
/// The number of grasp servers that accepted the git data.
pub fn accepted(&self) -> usize {
self.servers
.iter()
@@ -1595,12 +1539,10 @@ impl PushOutcome {
.count()
}
/// Servers that did not accept the push.
fn failing(&self) -> impl Iterator<Item = &GraspServerResult> {
self.servers.iter().filter(|server| server.reason.is_some())
}
/// One-line summary of every server failure, for error messages.
pub fn failure_summary(&self) -> String {
self.failing()
.map(|server| {
@@ -1628,7 +1570,6 @@ impl PushOutcome {
}
}
/// Collapse a multi-line relay or git error into one display line.
fn flatten_whitespace(text: &str) -> String {
const MAX_CHARS: usize = 200;
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
@@ -1760,7 +1701,6 @@ async fn stage_event_on_relay(
}
}
/// Push the repository at `path` to every grasp server in `servers`.
#[allow(clippy::too_many_arguments)]
async fn push_staged_to_grasps(
client: &Client,
@@ -1955,56 +1895,6 @@ mod tests {
);
}
fn grasp_list_event(servers: &[&str], created_at: u64) -> Event {
let keys = Keys::generate();
let tags: Vec<Tag> = servers
.iter()
.map(|url| Tag::parse(vec!["g", *url]).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitUserGraspList, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys)
.expect("signed event")
}
#[test]
fn grasp_list_servers_reads_g_tags_in_order() {
let event = grasp_list_event(
&["wss://first.example", "wss://second.example", "not a url"],
1000,
);
let servers = grasp_list_servers(&event);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://first.example", "wss://second.example"]
);
}
#[test]
fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() {
let old = grasp_list_event(&["wss://old.example"], 1000);
let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000);
// The newest list wins, its `g` order preserved.
let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://fresh.example", "wss://also.example"]
);
// The order of the input events does not matter.
let servers = latest_grasp_list_servers(vec![fresh, old]);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://fresh.example", "wss://also.example"]
);
// No list at all, empty, so the caller falls back to the defaults.
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
}
#[test]
fn transient_grasp_denials_are_classified() {
// The exact server rejection that started this work: the state event
@@ -2074,13 +1964,6 @@ mod tests {
));
}
#[test]
fn transient_denial_markers_match_case_insensitively() {
assert!(is_transient_grasp_denial(
"ERR NO STATE EVENTS IN PURGATORY"
));
}
#[test]
fn push_outcome_reports_partial_failures() {
let outcome = PushOutcome {
@@ -2110,39 +1993,4 @@ mod tests {
// The multi-line server reason is a single display line.
assert_eq!(warning.lines().count(), 1);
}
#[test]
fn push_outcome_with_every_server_ok_has_no_warning() {
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://relay.ngit.dev").expect("url"),
"https://relay.ngit.dev/npub1owner/repo.git".to_owned(),
),
],
state_event: None,
};
assert_eq!(outcome.accepted(), 2);
assert!(outcome.partial_warning().is_none());
assert_eq!(outcome.failure_summary(), "");
}
#[test]
fn push_outcome_without_servers_or_pushes_has_no_warning() {
assert!(PushOutcome::default().partial_warning().is_none());
}
#[test]
fn flatten_whitespace_collapses_and_clips_long_errors() {
assert_eq!(flatten_whitespace("a\n\n b \t c"), "a b c");
let long = "word ".repeat(100);
let flat = flatten_whitespace(&long);
assert!(flat.ends_with('…'));
assert_eq!(flat.chars().count(), 201);
}
}
+1 -107
View File
@@ -13,7 +13,6 @@ use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::{LocalReposStore, RepoListStore};
/// Delay between a refresh request and the actual re-computation.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How often the statuses are recomputed against the local refs.
@@ -29,7 +28,6 @@ const STATUS_POLL: Duration = Duration::from_secs(15);
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
const PUSH_POLL: Duration = Duration::from_secs(60);
/// Maximum checkouts considered per repository when computing statuses.
const MAX_STATUS_CHECKOUTS: usize = 8;
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
@@ -41,7 +39,6 @@ impl Global for GlobalCheckoutsStore {}
/// Carries the git facts needed to suggest a pull request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStatus {
/// The checkout folder.
pub path: PathBuf,
/// The branch checked out. A detached checkout is idle and yields no status.
pub branch: String,
@@ -95,9 +92,7 @@ pub struct CheckoutsStore {
///
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
/// A local status pass timer is pending.
local_pending: bool,
/// When the last full pass (with a remote refresh) completed.
///
@@ -108,7 +103,6 @@ pub struct CheckoutsStore {
}
impl CheckoutsStore {
/// Retrieve the global checkouts store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCheckoutsStore>().0.clone()
}
@@ -117,7 +111,6 @@ impl CheckoutsStore {
cx.set_global(GlobalCheckoutsStore(entity));
}
/// Create the store.
pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new();
@@ -176,7 +169,6 @@ impl CheckoutsStore {
}
}
/// Remember a successful local-checkout use.
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
return;
@@ -278,7 +270,6 @@ impl CheckoutsStore {
self.push_statuses.get(addr).cloned().unwrap_or_default()
}
/// The number of unpushed commits for a repository.
pub fn unpushed(&self, addr: &RepoAddr) -> usize {
self.push_statuses
.get(addr)
@@ -311,7 +302,6 @@ impl CheckoutsStore {
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
// Inputs snapshot, all cheap shared reads.
let records = {
let settings = SettingsStore::global(cx);
settings.read(cx).settings().checkouts.records.clone()
@@ -433,7 +423,7 @@ impl CheckoutsStore {
/// Schedule the fast local status pass, unless one is already pending.
///
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
/// the local refs — no network — so a new commit in a checkout surfaces in
/// the local refs, with no network, so a new commit in a checkout surfaces in
/// a second or two instead of at the next remote reconciliation.
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
if self.local_pending {
@@ -546,7 +536,6 @@ impl CheckoutsStore {
}
}
/// Identity of a repository URL.
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
@@ -557,7 +546,6 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
Some((host, parsed.port(), path))
}
/// Whether two repository URLs point at the same repository.
fn same_repo_url(a: &str, b: &str) -> bool {
match (url_identity(a), url_identity(b)) {
(Some(a), Some(b)) => a == b,
@@ -565,7 +553,6 @@ fn same_repo_url(a: &str, b: &str) -> bool {
}
}
/// Resolve the associations between local checkouts and announced repositories.
fn resolve_associations<'a>(
remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)],
@@ -608,7 +595,6 @@ fn resolve_associations<'a>(
out
}
/// The ready-to-contribute status of one checkout.
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
let branches = signed_git::worktree_branches(path).ok()?;
@@ -732,7 +718,6 @@ fn compute_statuses(
(statuses, push_statuses)
}
/// Whether the pull request `pr` already proposes the same change as `checkout`.
pub fn pr_proposes_checkout(
pr: &Event,
open: bool,
@@ -860,40 +845,6 @@ mod tests {
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_matches_scanned_repos_by_origin_and_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let announcements = vec![
announcement("repo", &["grasp://host/npub1x/repo"], None),
announcement("family", &[], Some(euc)),
];
let repo = addr("repo");
let family = addr("family");
let resolved = resolve_associations(
&[],
&[
// Origin matches modulo scheme and the `.git` suffix.
scanned("/clone", Some("https://host/npub1x/repo.git"), None),
// Root commit matches the family EUC.
scanned("/family-checkout", None, Some(euc)),
// Neither matches anything.
scanned("/unrelated", Some("https://elsewhere/x.git"), None),
],
&announcements,
);
assert_eq!(
resolved.get(&repo).expect("repo matches"),
&vec![PathBuf::from("/clone")]
);
assert_eq!(
resolved.get(&family).expect("family matches"),
&vec![PathBuf::from("/family-checkout")]
);
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_deduplicates_paths_remembering_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
@@ -1048,61 +999,4 @@ mod tests {
remote_run(&["commit", "-m", "remote work"]);
assert_eq!(checkout_push_status(&checkout, true), None);
}
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitPullRequest, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn status(branch: &str, head: &str) -> CheckoutStatus {
CheckoutStatus {
path: PathBuf::from("/checkout"),
branch: branch.to_owned(),
head: head.to_owned(),
base: "main".to_owned(),
ahead: 1,
}
}
#[test]
fn pr_proposes_checkout_matches_branch_or_tip() {
let author = "0000000000000000000000000000000000000000000000000000000000000002";
let tip = "aa231c4c6a5777dc89b42207b499891a344add5c";
// A matching `branch-name` covers the proposal.
let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]);
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// Without a branch-name tag, the `c` tip still matches for a renamed branch.
let pr = pr_event(
author,
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
);
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// Someone else's PR, a closed PR, a different branch and a missing tip.
// They all leave the checkout uncovered.
let pr = pr_event(author, &[&["branch-name", "feature"]]);
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
let other = pr_event(
"0000000000000000000000000000000000000000000000000000000000000003",
&[&["branch-name", "feature"]],
);
assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status));
let other_branch = pr_event(author, &[&["branch-name", "other"]]);
assert!(!pr_proposes_checkout(
&other_branch,
true,
other_branch.pubkey,
&status
));
}
}
-3
View File
@@ -12,14 +12,12 @@ impl Global for GlobalGitStore {}
pub struct GitStore(GitCache);
impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global.
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone()));
store
}
/// The app-wide clone cache.
pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone())
}
@@ -28,7 +26,6 @@ impl GitStore {
Self(GitCache::new(root.into()))
}
/// Underlying clone cache.
pub fn cache(&self) -> &GitCache {
&self.0
}
+1 -10
View File
@@ -11,7 +11,6 @@ use crate::backend::Backend;
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
/// Set once the stored state has been read for the current user.
loaded: bool,
}
@@ -21,12 +20,10 @@ impl Inbox {
&self.state
}
/// Whether the stored state has been read for the current user.
pub fn is_loaded(&self) -> bool {
self.loaded
}
/// Mark the events of one notification group read, then bound the id sets.
pub fn mark_read(
&mut self,
group: &[Event],
@@ -42,7 +39,7 @@ impl Inbox {
cx.notify();
}
/// Archive one notification group. Archived events are always read too.
/// Archived events are always read too.
pub fn mark_archived(
&mut self,
group: &[Event],
@@ -62,14 +59,12 @@ impl Inbox {
cx.notify();
}
/// Mark every known notification read.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
self.state.mark_all_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Load the stored state for current user.
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
self.state = InboxReadState::default();
self.loaded = false;
@@ -101,7 +96,6 @@ impl Inbox {
.detach();
}
/// Clear the state of the signed-out user.
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.state = InboxReadState::default();
self.loaded = false;
@@ -128,7 +122,6 @@ impl Inbox {
}
}
/// Derive the inbox home screen's threads for `me` from the local database.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
@@ -166,7 +159,6 @@ fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
@@ -223,7 +215,6 @@ async fn fetch_notifications(
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
loop {
// Keep only ids not walked yet, and remember them.
pending.retain(|id| seen.insert(*id));
if pending.is_empty() {
-1
View File
@@ -21,7 +21,6 @@ pub use repo::RepoStore;
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
/// Initialize the backend and stores, and install them as globals.
#[cfg(not(target_arch = "wasm32"))]
pub fn init(
db_path: impl AsRef<Path>,
-6
View File
@@ -53,7 +53,6 @@ impl Profile {
SharedString::from(shorten_pubkey(self.public_key, 4))
}
/// Avatar URL, if set.
pub fn picture(&self) -> Option<SharedString> {
self.metadata
.picture
@@ -83,7 +82,6 @@ struct GlobalProfileStore(Entity<ProfileStore>);
impl Global for GlobalProfileStore {}
impl ProfileStore {
/// Retrieve the global profile store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalProfileStore>().0.clone()
}
@@ -157,7 +155,6 @@ impl ProfileStore {
Profile::new(public_key, Metadata::default())
}
/// Load recently seen profiles from the local database.
fn load(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let client = backend.read(cx).client();
@@ -194,7 +191,6 @@ impl ProfileStore {
task.detach();
}
/// Re-read the latest metadata of an author from the local database.
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let client = backend.read(cx).client();
@@ -300,7 +296,6 @@ impl ProfileStore {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
// Wait for the first request of a batch.
match receiver.recv_async().await {
Ok(public_key) => {
batch.insert(public_key);
@@ -308,7 +303,6 @@ impl ProfileStore {
Err(_) => return Ok(()),
}
// Collect everything that arrives within the debounce window.
// The channel has no async timeout, race the receive against a timer.
let deadline = Instant::now() + BATCH_TIMEOUT;
loop {
-7
View File
@@ -1,15 +1,11 @@
/// Refresh coalescing shared by the event stores.
#[derive(Debug, Default)]
pub struct RefreshGate {
/// A run is in flight.
running: bool,
/// A request arrived while a run was in flight.
dirty: bool,
/// The debounce timer is pending.
debouncing: bool,
}
/// What a refresh request decided.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run or timer covers the request, start the debounce timer.
@@ -19,12 +15,10 @@ pub enum RefreshRequest {
}
impl RefreshGate {
/// Whether a run is in flight.
pub fn running(&self) -> bool {
self.running
}
/// Whether the debounce timer is pending.
pub fn debouncing(&self) -> bool {
self.debouncing
}
@@ -45,7 +39,6 @@ impl RefreshGate {
}
}
/// The debounce timer fired and the run starts now.
pub fn begin(&mut self) {
self.debouncing = false;
self.running = true;
+220 -131
View File
@@ -1,7 +1,6 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Error, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
@@ -22,9 +21,6 @@ use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum size of one patch event.
///
/// NIP-34 suggests patches when each event is under 60kb.
@@ -35,8 +31,18 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
/// Always derived from the local database.
pub struct RepoStore {
addr: RepoAddr,
/// NIP-34 address. `None` while the repository is local-only.
addr: Option<RepoAddr>,
/// Latest announcement. Seeded from the open-time hint, replaced by the
/// database's latest on the first pass. `None` while local-only.
pub announcement: Option<Announcement>,
/// Local working copy. The scan path for a local repository, kept when it is
/// later announced so the panel keeps its worktree.
pub path: Option<PathBuf>,
/// The first local pass has been applied.
///
/// Views distinguish "no data yet" from a genuinely empty repository with it.
pub loaded: bool,
/// Branch pointed to by `HEAD` in the latest state announcement.
pub head: Option<String>,
pub issues: Vec<Event>,
@@ -54,7 +60,6 @@ pub struct RepoStore {
///
/// Views key their derived-data caches to it instead of recomputing on every render.
version: u64,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
/// Non-fatal warning of the last action, if any.
///
@@ -80,54 +85,21 @@ pub struct RepoStore {
///
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
root_fetches: HashSet<EventId>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
_subscription: Subscription,
/// Backend subscription of an announced repository. `None` while local-only.
_subscription: Option<Subscription>,
}
impl RepoStore {
pub fn new(addr: RepoAddr, announced_relays: Vec<RelayUrl>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(&this.addr);
let author = update.author == this.addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag.
// Coordinate matching fails for them.
// Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag, NIP-34.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
// Locally published deletions may target any event of this repository.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) || deletion
}
_ => false,
};
if relevant {
this.refresh(cx);
}
});
pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
let subscription = Self::subscribe_backend(cx);
let announced_relays = hint
.as_ref()
.map(|announcement| announcement.relays.clone())
.unwrap_or_default();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
@@ -141,8 +113,10 @@ impl RepoStore {
});
Self {
addr,
announcement: None,
addr: Some(addr),
announcement: hint,
path: None,
loaded: false,
head: None,
issues: Vec::new(),
patches: Vec::new(),
@@ -160,13 +134,101 @@ impl RepoStore {
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: subscription,
_subscription: Some(subscription),
}
}
/// Returns the repository's address.
pub fn addr(&self) -> &RepoAddr {
&self.addr
/// Local repository discovered by the scan, not announced to NIP-34 yet.
pub fn new_local(path: PathBuf) -> Self {
Self {
addr: None,
announcement: None,
path: Some(path),
loaded: true,
head: None,
issues: Vec::new(),
patches: Vec::new(),
pull_requests: Vec::new(),
comments: Vec::new(),
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,
pushing: false,
cloning: false,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: None,
}
}
/// Switch a local repository to its NIP-34 mode, keeping its path.
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
self.addr = Some(announcement.addr());
self.announcement = Some(announcement.clone());
self.loaded = false;
if self._subscription.is_none() {
self._subscription = Some(Self::subscribe_backend(cx));
}
self.subscribe_remote(cx);
self.connect_announced_relays(&announcement.relays, cx);
self.refresh(cx);
}
fn subscribe_backend(cx: &mut Context<Self>) -> Subscription {
let backend = Backend::global(cx);
cx.subscribe(&backend, |this, _backend, event, cx| {
let Some(addr) = this.addr.as_ref() else {
return;
};
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(addr);
let author = update.author == addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag.
// Coordinate matching fails for them.
// Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag, NIP-34.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
// Locally published deletions may target any event of this repository.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) || deletion
}
_ => false,
};
if relevant {
this.refresh(cx);
}
})
}
pub fn addr(&self) -> Option<&RepoAddr> {
self.addr.as_ref()
}
/// Returns the repository's name, or `Unknown` when not known.
@@ -198,6 +260,10 @@ impl RepoStore {
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
let new: Vec<RelayUrl> = relays
.iter()
.filter(|url| !self.repo_relays.contains(*url))
@@ -210,17 +276,18 @@ impl RepoStore {
self.repo_relays.extend(new.iter().cloned());
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
});
}
/// Fetch this repository's events from the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(Self::repo_filters(&addr), cx);
@@ -228,25 +295,30 @@ impl RepoStore {
}
/// Re-query the local database and update all fields.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.addr.is_none() {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
})
.detach();
self.run_refresh(cx);
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
self.refresh.begin();
let backend = Backend::global(cx);
let client = backend.read(cx).client();
let addr = self.addr.clone();
let work = cx.background_spawn(async move {
let (announcements, states, activity, deletion_events) = async {
@@ -402,10 +474,22 @@ impl RepoStore {
// is polled in bursts while a sync is in flight; notifying on
// every identical pass would re-render the repository panel
// several times for no visible change.
//
// The first pass is the exception: it must notify even when it
// found nothing, so views can leave their loading state and show
// the empty result.
//
// Keep the open-time hint until that first pass has confirmed what
// the database holds; afterwards the database is the truth,
// including a deletion.
let keep_hint = announcement.is_none() && !this.loaded;
let first_pass = !this.loaded;
let head_changed = state
.as_ref()
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
let changed = this.announcement != announcement
let changed = first_pass
|| (!keep_hint && this.announcement != announcement)
|| head_changed
|| this.issues != issues
|| this.patches != patches
@@ -413,7 +497,9 @@ impl RepoStore {
|| this.comments != comments
|| this.status_by_root != status_by_root;
this.announcement = announcement;
if !keep_hint {
this.announcement = announcement;
}
// The announcement may list relays for this repository's activity.
// Connect to any we have not fetched from yet.
@@ -435,6 +521,7 @@ impl RepoStore {
this.status_by_root = status_by_root;
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.
@@ -493,8 +580,6 @@ impl RepoStore {
status_of(&self.status_by_root, root)
}
/// Refresh generation, incremented on every applied refresh.
/// Views use it to key their derived-data caches.
pub fn version(&self) -> u64 {
self.version
}
@@ -520,13 +605,19 @@ impl RepoStore {
/// The author is the public key of the repository address.
/// Only the author may manage pull requests, close, reopen or merge.
pub fn is_author(&self, user: &PublicKey) -> bool {
&self.addr.public_key == user
self.addr
.as_ref()
.is_some_and(|addr| &addr.public_key == user)
}
/// Open an issue on this repository.
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let builder = GitIssue {
repository: self.addr.clone(),
repository: addr,
content,
subject,
labels: Vec::new(),
@@ -536,7 +627,6 @@ impl RepoStore {
self.publish(builder, cx);
}
/// Comments on a root event, an issue or PR, oldest first.
pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> {
self.comments
.iter()
@@ -558,6 +648,11 @@ impl RepoStore {
content: String,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let relay_hint = self
.announcement
.as_ref()
@@ -565,12 +660,11 @@ impl RepoStore {
.cloned();
self.publish(
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
comment_builder(root, parent, relay_hint.as_ref(), &addr, content),
cx,
);
}
/// Open a pull request on this repository.
#[allow(clippy::too_many_arguments)]
pub fn open_pull_request(
&mut self,
@@ -586,6 +680,11 @@ impl RepoStore {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let series: Vec<String> = signed_git::split_patch_series(&patch)
.into_iter()
.map(str::to_owned)
@@ -629,8 +728,7 @@ impl RepoStore {
// The author's npub names their GRASP-06 namespace, `/prs/...`.
let author_npub = user.to_bech32().unwrap();
let addr = self.addr.clone();
let owner = self.addr.public_key;
let owner = addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let repo_id = addr.identifier.clone();
let base_npub = owner.to_bech32().unwrap();
@@ -746,7 +844,7 @@ impl RepoStore {
let clone = pr_clone_urls(prs_urls, base_clone);
let builder = GitPullRequest {
repository: this.addr.clone(),
repository: addr.clone(),
content: description,
subject,
labels: Vec::new(),
@@ -965,8 +1063,11 @@ impl RepoStore {
.map(|p| p.id)
});
let addr = self.addr.clone();
let owner = self.addr.public_key;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let owner = addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let clone: Vec<Url> = self
@@ -994,9 +1095,9 @@ impl RepoStore {
});
}
let builder = this.update(cx, |this, _cx| {
let builder = this.update(cx, |_this, _cx| {
let builder = GitPullRequestUpdate {
repository: this.addr.clone(),
repository: addr.clone(),
pull_request_event: root.id,
pull_request_author: root.pubkey,
current_commit,
@@ -1053,6 +1154,11 @@ impl RepoStore {
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let maintainers = self
.announcement
.as_ref()
@@ -1078,19 +1184,23 @@ impl RepoStore {
let builder = EventBuilder::new(status.kind(), "").tags([
root_ref,
Tag::public_key(self.addr.public_key),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
Tag::coordinate(addr, None),
]);
self.publish(builder, cx);
}
/// Merge a pull request.
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let is_author = Backend::global(cx)
.read(cx)
.current_user()
@@ -1101,7 +1211,6 @@ impl RepoStore {
}
let cache = GitStore::global(cx).cache().clone();
let addr = self.addr.clone();
let clone_urls: Vec<Url> = self
.announcement
@@ -1170,17 +1279,17 @@ impl RepoStore {
/// The latest announcement of this repository,
/// for operations that need its clone URLs and relays.
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
let addr = self.addr.as_ref()?;
self.announcement.clone().or_else(|| {
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == self.addr)
.find(|announcement| announcement.addr() == *addr)
.cloned()
})
}
/// Re-push the repository's refs to its announced grasp servers, republish.
pub fn push_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
if self.pushing {
return Task::ready(Err(anyhow::anyhow!(
@@ -1225,7 +1334,6 @@ impl RepoStore {
})
}
/// Push the unpushed commits of the local checkout at `path`.
pub fn push_checkout(
&mut self,
path: PathBuf,
@@ -1237,6 +1345,10 @@ impl RepoStore {
)));
}
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
@@ -1244,7 +1356,6 @@ impl RepoStore {
// The state event's `HEAD` stays the announced default branch.
// The checkout may be on a side branch.
let head = self.head.clone();
let addr = self.addr.clone();
self.pushing = true;
self.last_error = None;
@@ -1292,7 +1403,9 @@ impl RepoStore {
/// Only the repository owner may delete it. The lists update when the
/// deletion events arrive.
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let addr = self.addr.clone();
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
self.last_error = None;
let backend = Backend::global(cx);
@@ -1322,12 +1435,16 @@ impl RepoStore {
"A clone of this repository is already in progress"
)));
}
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
let clone_urls = announcement.clone.clone();
let addr = self.addr.clone();
self.cloning = true;
self.last_error = None;
@@ -1346,7 +1463,6 @@ impl RepoStore {
match &result {
Ok(()) => {
// Remember the clone as a checkout of this repository.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.record(destination.clone(), addr.clone(), cx);
@@ -1364,7 +1480,11 @@ impl RepoStore {
})
}
/// Fail an operation whose announcement is not loaded yet.
fn not_announced(&mut self, cx: &mut Context<Self>) {
self.last_error = Some("This repository is not published to Nostr yet".into());
cx.notify();
}
fn action_error(
&mut self,
message: impl Into<String>,
@@ -1387,11 +1507,15 @@ impl RepoStore {
euc: Option<&str>,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
return;
};
let mut tags = vec![
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
Tag::public_key(self.addr.public_key),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
Tag::coordinate(addr, None),
];
if let Some(euc) = euc
@@ -1484,7 +1608,6 @@ where
events.into_iter().max_by_key(|e| e.created_at)
}
/// Status of `root` from the precomputed map.
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root
.get(&root.id)
@@ -1492,7 +1615,6 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> Rep
.unwrap_or(RepoStatus::Open)
}
/// Resolve every root event's status in one pass.
fn resolve_statuses(
issues: &[Event],
patches: &[Event],
@@ -1659,13 +1781,6 @@ mod tests {
);
}
#[test]
fn no_commit_without_header() {
assert_eq!(patch_current_commit(""), None);
assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None);
assert_eq!(patch_current_commit("From short\n"), None);
}
#[test]
fn comment_builder_follows_nip22() {
let keys = Keys::generate();
@@ -1701,30 +1816,4 @@ mod tests {
// Signed's own `references_root` must keep matching the comment.
assert!(signed_core::references_root(&event, &root.id));
}
#[test]
fn comment_builder_replies_nest_under_the_parent() {
let keys = Keys::generate();
let root = EventBuilder::new(Kind::GitIssue, "issue body")
.finalize(&keys)
.expect("signed event");
let parent = EventBuilder::new(Kind::Comment, "first comment")
.finalize(&keys)
.expect("signed event");
let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo");
let event = comment_builder(&root, Some(&parent), None, &addr, "reply".into())
.finalize(&keys)
.expect("signed event");
// The uppercase `E` tag still scopes the root event.
// The lowercase `e` tag references the parent comment.
let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
assert_eq!(root_ref.as_slice()[1], root.id.to_hex());
assert_eq!(parent_ref.as_slice()[1], parent.id.to_hex());
// The reply still threads under the root for Signed's own display.
assert!(signed_core::references_root(&event, &root.id));
}
}
+5 -35
View File
@@ -18,18 +18,14 @@ impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
@@ -38,7 +34,6 @@ impl LocalReposStore {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away.
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
@@ -67,7 +62,6 @@ impl LocalReposStore {
cx.notify();
}
/// Re-run the scan.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
@@ -117,11 +111,6 @@ impl LocalReposStore {
}
}
/// Delay between a refresh request and the actual re-query.
///
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How far back activity events count toward a repository's last activity.
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
@@ -159,13 +148,11 @@ pub struct RepoListStore {
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
_subscription: Subscription,
}
impl RepoListStore {
/// Retrieve the global repository list store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRepoListStore>().0.clone()
}
@@ -174,7 +161,6 @@ impl RepoListStore {
cx.set_global(GlobalRepoListStore(entity));
}
/// Create the store listing all announcements.
pub fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let weak = cx.entity().downgrade();
@@ -220,7 +206,7 @@ impl RepoListStore {
cx.defer(move |cx| {
weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
this.refresh_initial(cx);
this.refresh(cx);
})
.ok();
});
@@ -243,7 +229,6 @@ impl RepoListStore {
.collect()
}
/// Negentropy-sync announcements with the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
@@ -254,33 +239,18 @@ impl RepoListStore {
});
}
/// One-shot initial load.
///
/// Query the local database immediately, no debounce.
/// Stored announcements appear as soon as the app opens.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
/// Re-query the local database.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
})
.detach();
self.run_refresh(cx);
}
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
-21
View File
@@ -162,24 +162,3 @@ fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
})
.child(Icon::new(IconName::ChevronDown).xsmall())
}
#[cfg(test)]
mod tests {
use gpui::div;
use super::*;
#[test]
fn dropdown_button_builder_state() {
let button = DropdownButton::new("issues")
.action(div())
.anchor(Anchor::BottomLeft)
.dropdown_menu(|menu, _, _| menu);
assert!(button.action.is_some());
// The caret is `None` until render, which falls back to the default.
assert!(button.caret.is_none());
assert!(button.menu.is_some());
assert_eq!(button.anchor, Anchor::BottomLeft);
}
}
+2
View File
@@ -2,6 +2,7 @@ mod dropdown_button;
mod nav_item;
mod pixel_avatar;
mod placeholder;
mod ref_selector;
mod segment_button;
mod setting;
mod status_badge;
@@ -17,6 +18,7 @@ pub use dropdown_button::DropdownButton;
pub use nav_item::NavItem;
pub use pixel_avatar::PixelAvatar;
pub use placeholder::placeholder;
pub use ref_selector::ref_selector_trigger;
pub use segment_button::{CountBadge, SegmentButton};
pub use setting::{SelectOption, setting_block, setting_row};
pub use status_badge::status_badge;
-1
View File
@@ -35,7 +35,6 @@ impl NavItem {
}
}
/// A trailing element rendered at the right edge of the row
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
+5 -23
View File
@@ -175,9 +175,13 @@ mod tests {
}
#[test]
fn pattern_is_mirror_symmetric() {
fn pattern_properties() {
for seed in 0..50 {
let pattern = pattern(seed);
assert!(
count_filled(&pattern) >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE {
assert_eq!(
@@ -188,31 +192,9 @@ mod tests {
}
}
}
}
#[test]
fn pattern_has_minimum_fill() {
for seed in 0..50 {
let pattern = pattern(seed);
assert!(
count_filled(&pattern) >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
}
}
#[test]
fn pattern_is_deterministic() {
for seed in [0, 1, 42, u64::MAX] {
assert_eq!(pattern(seed), pattern(seed));
}
assert_ne!(pattern(42), pattern(43));
}
#[test]
fn fnv1a_is_stable_and_distinct() {
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a(b"repo"), fnv1a(b"repo"));
assert_ne!(fnv1a(b"repo:a"), fnv1a(b"repo:b"));
}
}
+41
View File
@@ -0,0 +1,41 @@
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, SharedString, div};
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
use gpui_component::searchable_list::SearchableVec;
use gpui_component::{ActiveTheme, Icon, Sizable, h_flex};
/// The kind icon, the selection or placeholder, and the caret. `Combobox`
/// replaces its default trigger entirely, the only way to show an icon inside it.
pub fn ref_selector_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName,
cx: &App,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_center()
.child(Icon::new(icon).small().flex_shrink_0())
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
.child(
ctx.selection()
.first()
.map(|(_, item)| item.clone())
.or_else(|| ctx.placeholder().cloned())
.unwrap_or_default(),
),
)
.child(Caret::new(ctx.size()).text_color(muted))
.into_any_element()
}
-2
View File
@@ -21,12 +21,10 @@ impl SelectOption {
}
}
/// The stored value of this option.
pub fn value(&self) -> &SharedString {
&self.value
}
/// The display label of this option.
pub fn label(&self) -> &SharedString {
&self.label
}
-1
View File
@@ -35,7 +35,6 @@ where
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
)
.on_click(move |_event, window, cx| {
// Folders expand/collapse via the tree itself.
if is_folder {
return;
}
-1
View File
@@ -33,7 +33,6 @@ mod tests {
),
"30617:a008...3564d:ngit"
);
// Too short to save space with the ellipsis, left alone.
assert_eq!(middle_truncate("short", 10, 10), "short");
}
}
+1 -5
View File
@@ -39,10 +39,6 @@ mod tests {
assert_eq!(relative_time(now - 3 * 86_400), "3d ago");
assert_eq!(relative_time(now - 60 * 86_400), "2mo ago");
assert_eq!(relative_time(now - 800 * 86_400), "2y ago");
}
#[test]
fn clamps_future_timestamps() {
assert_eq!(relative_time(Timestamp::now() + 600), "just now");
assert_eq!(relative_time(now + 600), "just now");
}
}
+180 -30
View File
@@ -13,34 +13,25 @@ use gpui_component::resizable::{resizable_panel, v_resizable};
use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner;
use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
use signed_ui::{placeholder, tree_row};
use utils::relative_time_secs;
use crate::views::repo::helpers::{
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
};
use crate::views::tree::{build_tree_items, tree_items};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Tree and per-file diff body, shared by the commit diff and compare views.
pub struct DiffPane {
/// Loaded diff, `None` until [`Self::set_diff`] is called.
diff: Option<CommitDiff>,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// Rows of the selected file's diff, hunk headers and lines.
rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle,
}
@@ -56,12 +47,10 @@ impl DiffPane {
}
}
/// The loaded diff, for stats and badges in the host's header.
pub fn diff(&self) -> Option<&CommitDiff> {
self.diff.as_ref()
}
/// Replace the diff and rebuild the tree and the selected file's rows.
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
let mut paths: Vec<PathBuf> = diff
.files
@@ -86,9 +75,6 @@ impl DiffPane {
}
}
/// Forget the diff, e.g. when the compared branches changed.
///
/// Clears the tree, the selection and the diff rows.
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.diff = None;
self.selected_file = None;
@@ -99,14 +85,12 @@ impl DiffPane {
});
}
/// Show the diff of the file at `path`, selected in the tree.
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
self.set_diff_rows(path);
cx.notify();
}
/// Rebuild the virtual list state for `path` and scroll back to the top.
fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else {
return;
@@ -119,7 +103,6 @@ impl DiffPane {
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
/// One row of the changed-files tree, icon and name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
@@ -136,7 +119,6 @@ impl DiffPane {
})
}
/// Left column showing the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
@@ -166,7 +148,6 @@ impl DiffPane {
.into_any_element()
}
/// Right column, header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(diff) = self.diff.as_ref() else {
return placeholder("No changes", cx);
@@ -184,7 +165,6 @@ impl DiffPane {
self.render_file_diff(file, cx.entity(), cx)
}
/// The diff of one file, with a header showing status and stats.
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status {
DiffStatus::Added => "A",
@@ -311,19 +291,14 @@ impl Render for DiffPane {
}
}
/// Detail panel showing the diff of one commit.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown in the header and tab title.
commit: FileCommit,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
pane: Entity<DiffPane>,
}
@@ -359,7 +334,6 @@ impl CommitDiffView {
}
}
/// Load the commit diff and the full commit metadata.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -407,7 +381,6 @@ impl CommitDiffView {
task.detach();
}
/// Header with the commit id, summary, author/time and overall change stats.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit;
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
@@ -535,3 +508,180 @@ impl Render for CommitDiffView {
.child(resizable_panel().child(body))
}
}
const GUTTER_WIDTH: f32 = 44.;
const DIFF_ROW_HEIGHT: f32 = 20.;
#[derive(Clone, Copy)]
enum DiffRow {
Hunk {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
},
Line {
hunk: usize,
line: usize,
},
}
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
old_start: hunk.old_start,
old_lines: hunk.old_lines,
new_start: hunk.new_start,
new_lines: hunk.new_lines,
});
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
hunk: hunk_ix,
line,
}));
}
rows
}
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
old_start,
old_lines,
new_start,
new_lines,
} => div()
.px_2()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
}
}
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap, the virtual list assumes every row has the same height.
// Long lines are clipped instead of wrapped.
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.items_center()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
pub(crate) fn commit_row(
ix: usize,
commit: &FileCommit,
on_click: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> AnyElement {
h_flex()
.id(ix)
.px_4()
.h(px(COMMIT_ROW_HEIGHT))
.w_full()
.gap_3()
.items_center()
.border_b(px(1.))
.border_color(cx.theme().border)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.justify_center()
.child(
h_flex()
.gap_2()
.items_center()
.overflow_hidden()
.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
.child(
h_flex()
.gap_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.author.clone())
.child(relative_time_secs(commit.time)),
),
)
.on_click(move |_event, window, cx| on_click(window, cx))
.into_any_element()
}
+3 -4
View File
@@ -2,8 +2,7 @@ use gpui::prelude::*;
use gpui::{AnyElement, App, SharedString, div};
use gpui_component::ActiveTheme;
/// Progress of an async dialog action: a busy flag disabling the form,
/// and an error line shown under it.
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
#[derive(Debug, Default)]
pub struct DialogProgress {
pub busy: bool,
@@ -11,13 +10,13 @@ pub struct DialogProgress {
}
impl DialogProgress {
/// An action started, disable the form and clear the previous error.
/// Marks an action as started, disabling the form and clearing the previous error.
pub fn begin(&mut self) {
self.busy = true;
self.error = None;
}
/// An action failed, re-enable the form and surface `message`.
/// Marks an action as failed, enabling the form and showing `message`.
pub fn fail(&mut self, message: impl Into<SharedString>) {
self.busy = false;
self.error = Some(message.into());
+217
View File
@@ -0,0 +1,217 @@
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, Entity, SharedString, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId, PublicKey};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::UserAvatar;
use utils::relative_time;
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
&store.issues
}
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
&store.pull_requests
}
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(text.to_string())
.into_any_element()
}
pub(crate) fn sidebar_section(
store: &Entity<RepoStore>,
id: EventId,
roots: fn(&RepoStore) -> &[Event],
top_gap: bool,
cx: &App,
) -> AnyElement {
let store = store.read(cx);
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
// The caller bails out when the root is missing.
return div().into_any_element();
};
let profile_store = ProfileStore::global(cx);
// Participants, the root author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![root.pubkey];
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex);
participants.dedup();
// Labels are NIP-34 `t` hashtag tags on the event.
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
v_flex()
.w(px(240.))
.h_full()
.flex_none()
.px_4()
.gap_4()
.border_l(px(1.))
.border_color(cx.theme().sidebar_border)
.child(
v_flex()
.when(top_gap, |this| this.mt_4())
.gap_2()
.child(sidebar_title("Participants", cx))
.children(participants.iter().map(|pubkey| {
let profile = profile_store.read(cx).get(pubkey);
let name = profile.name();
let picture = profile.picture();
h_flex()
.gap_1()
.items_center()
.child(UserAvatar::new(name.clone()).picture(picture))
.child(div().text_sm().truncate().text_ellipsis().child(name))
.into_any_element()
})),
)
.child(
v_flex()
.gap_2()
.child(sidebar_title("Labels", cx))
.map(|this| {
if labels.is_empty() {
this.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("None yet."),
)
} else {
this.child(h_flex().gap_1().children({
let mut items = vec![];
for label in labels.iter() {
items.push(
Tag::secondary()
.outline()
.xsmall()
.child(SharedString::from(label)),
);
}
items
}))
}
}),
)
.into_any_element()
}
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
let store = store.read(cx);
let comments: Vec<&Event> = store.comments_of(&root).collect();
let title = SharedString::from(format!("Discussions {}", comments.len()));
v_flex()
.gap_4()
.child(div().text_xs().font_semibold().child(title))
.children(comments.iter().map(|comment| {
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
let content = SharedString::from(comment.content.as_str());
v_flex()
.gap_1()
.p_3()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(author),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child("commented"),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
/// `roots` selects the root's list within the store, issues or pull requests.
pub(crate) fn comment_form(
store: &Entity<RepoStore>,
root: EventId,
roots: fn(&RepoStore) -> &[Event],
comment_input: &Entity<TextareaState>,
button_id: &'static str,
cx: &App,
) -> AnyElement {
let comment_input = comment_input.clone();
let store = store.clone();
v_flex()
.gap_2()
.child(
Textarea::new(&comment_input)
.h_24()
.text_color(cx.theme().muted_foreground)
.bg(cx.theme().muted),
)
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::Markdown).small())
.child("Markdown is supported"),
)
.child(
Button::new(button_id)
.primary()
.label("Comment")
.tooltip("Post comment")
.on_click(move |_event, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = roots(store.read(cx))
.iter()
.find(|event| event.id == root)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
)
.into_any_element()
}
+2 -39
View File
@@ -21,20 +21,13 @@ use utils::relative_time;
use super::{RepoItem, open_repo_item};
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Extra list rows measured above and below the visible area.
const LIST_OVERDRAW: Pixels = px(400.);
/// Maximum number of sub-activity lines shown under a thread row.
const MAX_SUB_ACTIVITIES: usize = 5;
/// A repository's slice of the inbox: the threads that belong to it.
struct InboxSection {
/// Repository the section groups, `None` for items without one.
/// `None` for items without a repository.
address: Option<RepoAddr>,
/// Number of threads with an unread event.
unread: usize,
/// Indices into the threads, newest activity first.
entries: Vec<usize>,
@@ -54,15 +47,11 @@ pub struct InboxView {
dock_area: WeakEntity<DockArea>,
/// One row per thread, merging notifications and own activity, newest first.
threads: Arc<Vec<InboxItem>>,
/// The threads grouped by repository, newest first.
sections: Arc<Vec<InboxSection>>,
/// The flattened repository headers and rows of the list.
rows: Arc<Vec<InboxRow>>,
/// Number of non-archived threads with an unread event.
unread_count: usize,
/// Copy of the global read state the current lists were derived with.
state: InboxReadState,
/// Set once the global state has been read for the current user.
state_loaded: bool,
refresh: RefreshGate,
list: ListState,
@@ -118,7 +107,6 @@ impl InboxView {
}
}
/// Mark every known notification read.
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
@@ -136,7 +124,6 @@ impl InboxView {
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
}
/// Re-derive from the global state when it is loaded or changes.
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
@@ -170,7 +157,6 @@ impl InboxView {
}
}
/// Handle a backend event that can change the derived sections.
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
BackendEvent::NostrUpdate(updates) => {
@@ -192,7 +178,6 @@ impl InboxView {
}
}
/// One-shot initial load, no debounce.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
@@ -202,7 +187,6 @@ impl InboxView {
self.run_refresh(cx);
}
/// Re-query the local database.
fn refresh(&mut self, cx: &mut Context<Self>) {
if !self.state_loaded {
return;
@@ -218,7 +202,6 @@ impl InboxView {
}));
}
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
@@ -264,7 +247,6 @@ impl InboxView {
}));
}
/// Recompute the unread and archived flags from the current state.
fn regroup(&mut self, cx: &mut Context<Self>) {
let mut items = (*self.threads).clone();
@@ -277,7 +259,6 @@ impl InboxView {
self.rebuild(cx);
}
/// Regroup the current threads by repository and flatten them into rows.
fn rebuild(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let repo_list = RepoListStore::global(cx);
@@ -308,7 +289,6 @@ impl InboxView {
self.rows = Arc::new(rows);
}
/// Group the threads into one section per repository.
fn group_sections(&self) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
@@ -349,7 +329,6 @@ impl InboxView {
sections
}
/// Flatten the sections into the list of repository headers and their rows.
fn flatten_rows(&self, sections: &[InboxSection]) -> Vec<InboxRow> {
let mut rows = Vec::new();
@@ -369,7 +348,6 @@ impl InboxView {
rows
}
/// Forget everything derived for the current user.
fn clear(&mut self) {
self.threads = Arc::new(Vec::new());
self.sections = Arc::new(Vec::new());
@@ -393,16 +371,6 @@ impl InboxView {
return;
};
let Some(announcement) = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == address)
.cloned()
else {
return;
};
let item = match kind {
Some(Kind::GitIssue) => RepoItem::Issue(root),
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
@@ -410,7 +378,7 @@ impl InboxView {
_ => return,
};
open_repo_item(&self.dock_area, &announcement, item, window, cx);
open_repo_item(&self.dock_area, &address, None, item, window, cx);
}
fn render_entry(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
@@ -455,7 +423,6 @@ impl InboxView {
}
}
/// Display name of the repository at `addr`, from the announcement store.
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
let repo_list = RepoListStore::global(cx);
let addr = addr?;
@@ -467,7 +434,6 @@ fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
.map(|announcement| announcement.name().map(SharedString::from))
}
/// Header of a repository section.
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
let name =
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
@@ -491,7 +457,6 @@ fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
.into_any_element()
}
/// Placeholder under a repository header that has nothing to show.
fn empty_section_row(cx: &App) -> AnyElement {
h_flex()
.h_12()
@@ -610,7 +575,6 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
.into_any_element()
}
/// Phrase describing an activity event, read as `[name] [phrase]`.
fn activity_phrase(kind: Kind) -> &'static str {
if kind == COVER_NOTE_KIND {
return "added a note";
@@ -630,7 +594,6 @@ fn activity_phrase(kind: Kind) -> &'static str {
}
}
/// Centered muted icon and message filling its container.
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
v_flex()
.w_full()
+14 -8
View File
@@ -1,8 +1,8 @@
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
relative,
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription,
Window, div, relative,
};
use gpui_component::input::TextareaState;
use gpui_component::scroll::ScrollableElement;
@@ -13,16 +13,14 @@ use signed_state::{ProfileStore, RepoStore};
use signed_ui::{UserAvatar, placeholder, status_badge};
use utils::relative_time;
use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
/// Detail panel of a single issue.
pub struct IssueDetailView {
/// Repo store holding the issues and their statuses.
focus_handle: FocusHandle,
store: Entity<RepoStore>,
issue_id: EventId,
/// Input state of the comment textarea.
comment_input: Entity<TextareaState>,
focus_handle: FocusHandle,
_subscription: Subscription,
}
impl IssueDetailView {
@@ -35,11 +33,14 @@ impl IssueDetailView {
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
Self {
focus_handle: cx.focus_handle(),
store,
issue_id,
comment_input,
_subscription: subscription,
}
}
}
@@ -81,7 +82,12 @@ impl Render for IssueDetailView {
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
return placeholder("Issue not found", cx);
// The store has not applied its first pass yet, the issue may still arrive.
return if store.loaded {
placeholder("Issue not found", cx)
} else {
placeholder("Loading issue...", cx)
};
};
let (title, author, picture, status, age, issue_id, content) = {
+66 -62
View File
@@ -4,8 +4,8 @@ use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, size,
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
@@ -25,22 +25,16 @@ pub(super) mod detail;
use self::detail::IssueDetailView;
/// Height of one issue row in the virtual list.
const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IssueFilter {
/// Every issue, regardless of status.
All,
/// Issues whose resolved status is [`RepoStatus::Open`].
Open,
/// Issues whose resolved status is [`RepoStatus::Closed`].
Closed,
}
impl IssueFilter {
/// Whether an issue with `status` is included by this filter.
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
@@ -52,37 +46,37 @@ impl IssueFilter {
pub struct IssuesView {
focus_handle: FocusHandle,
/// Dock area the issue detail panel is opened in.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the issues and their statuses.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Filter selected in the header filter buttons.
filter: IssueFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// The filtered issue count [`Self::item_sizes`] was built for.
issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`].
visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>,
/// Virtual list state of the issues list.
// A filter change notifies even when the visible rows are unchanged,
// e.g. switching between two empty filters.
synced_filter: IssueFilter,
scroll_handle: VirtualListScrollHandle,
_subscription: Subscription,
}
impl IssuesView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
_window: &mut Window,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild(cx);
});
cx.defer_in(window, |this, _window, cx| {
this.rebuild(cx);
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -90,14 +84,60 @@ impl IssuesView {
repo_name,
filter: IssueFilter::Open,
item_sizes: Rc::new(Vec::new()),
issue_len: 0,
visible_issues: Vec::new(),
counts: (0, 0, 0),
cache_key: None,
synced_filter: IssueFilter::Open,
scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
}
}
fn rebuild(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
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)
};
let filter_changed = self.synced_filter != filter;
let visible_issues_changed = self.visible_issues != visible_issues;
let counts_changed = self.counts != counts;
if !filter_changed && !visible_issues_changed && !counts_changed {
return;
}
self.item_sizes = Rc::new(vec![
size(px(0.), px(ISSUE_ROW_HEIGHT));
visible_issues.len()
]);
self.synced_filter = filter;
self.visible_issues = visible_issues;
self.counts = counts;
cx.notify();
}
/// Open the detail panel of `issue_id` in the dock area.
fn open_issue_detail(
&mut self,
@@ -178,7 +218,6 @@ impl IssuesView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild.
let (total, open, closed) = self.counts;
h_flex()
@@ -199,7 +238,7 @@ impl IssuesView {
.selected(self.filter == IssueFilter::All)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::All;
cx.notify();
this.rebuild(cx);
})),
)
.child(
@@ -209,7 +248,7 @@ impl IssuesView {
.selected(self.filter == IssueFilter::Open)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Open;
cx.notify();
this.rebuild(cx);
})),
)
.child(
@@ -219,7 +258,7 @@ impl IssuesView {
.selected(self.filter == IssueFilter::Closed)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Closed;
cx.notify();
this.rebuild(cx);
})),
),
)
@@ -236,7 +275,6 @@ impl IssuesView {
}
}
/// Open the new issue dialog, a title and a content input.
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
@@ -324,41 +362,7 @@ impl Focusable for IssuesView {
impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Rows and counts are rebuilt only when the store refreshed or filter changed.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize);
self.visible_issues = 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();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_issues.len();
// The virtual list's item count comes from `item_sizes`.
// Rebuild it whenever the filtered issue count changes.
if count != self.issue_len {
self.issue_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
}
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
+2
View File
@@ -1,5 +1,6 @@
mod commit_diff;
mod dialog_state;
pub(crate) mod discussion;
mod inbox;
mod issues;
mod pull_requests;
@@ -7,6 +8,7 @@ mod repo;
mod repo_list;
mod send_patch;
pub(crate) mod sidebar;
pub(crate) mod tree;
pub use inbox::InboxView;
pub use repo::RepoDetailView;
+214 -171
View File
@@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, relative, size,
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
@@ -20,9 +20,9 @@ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind};
use nostr::prelude::{Event, EventId, Kind, Url};
use signed_core::{
activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
merge_base_of, pull_request_patch,
};
use signed_git::{FileCommit, patch_commits, patch_diffs};
@@ -31,44 +31,56 @@ use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
use utils::{relative_time, relative_time_secs};
use crate::views::commit_diff::{CommitDiffView, DiffPane};
use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
/// Height of one commit row in the commits tab's virtual list.
const ROW_HEIGHT: f32 = 37.;
/// Shown once the store's first pass is applied and the root PR is still absent.
const NOT_FOUND: &str = "Pull request not found";
/// A store refresh re-binds the panel, and reloads only when these change.
#[derive(Clone, PartialEq, Eq)]
struct PrBinding {
description: String,
patch: String,
tip: Option<String>,
base: Option<String>,
clone_urls: Vec<Url>,
addr: RepoAddr,
has_patch_link: bool,
}
/// Detail panel of a single pull request.
pub struct PullRequestDetailView {
focus_handle: FocusHandle,
/// Dock area where new panels, e.g. commit diffs, are added.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the PR, its status and comments.
store: Entity<RepoStore>,
/// Event id of the root PR event, kind 1618.
/// Updates are revisions.
/// Event id of the root PR event, kind 1618. Updates are revisions.
pr_id: EventId,
/// Input state of the comment textarea.
comment_input: Entity<TextareaState>,
/// Display name of the repository, for panels opened from here.
repo_name: SharedString,
/// Local clone the PR's git changes come from.
worktree: Option<PathBuf>,
/// Root PR's content, shown as plain text.
description: SharedString,
/// Tip commit of the PR, the latest update's `c` tag or the root's.
/// Tip commit of the PR, from the latest update's `c` tag or the root.
current_commit: Option<SharedString>,
/// Commits of the patch series, in patch order, oldest first.
commits: Vec<FileCommit>,
/// The patch is being parsed on a background task.
loading: bool,
error: Option<SharedString>,
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
/// Root PR inputs the in-flight diff load was started for.
bound: Option<PrBinding>,
/// Generation of the in-flight diff load. Stale results are discarded.
load_generation: u64,
/// 0 = Discussion, 1 = Files, 2 = Commits.
active_tab: usize,
/// Changed-files explorer and per-file diff, like the commit and compare views.
pane: Entity<DiffPane>,
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle,
/// The dock caches item panels, so without this observer a panel opened
/// before the store loaded would stay on its placeholder.
_subscription: Subscription,
}
impl PullRequestDetailView {
@@ -85,9 +97,11 @@ impl PullRequestDetailView {
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx));
// Defer loading until the window is ready, like the commit diff view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
cx.defer_in(window, |this, _window, cx| {
this.sync(cx);
});
Self {
@@ -103,156 +117,217 @@ impl PullRequestDetailView {
commits: Vec::new(),
loading: true,
error: None,
bound: None,
load_generation: 0,
active_tab: 0,
pane,
commit_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
}
}
/// Snapshot the PR events from the store.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
/// Snapshot the root PR from the store and reload the diff when it changed.
///
/// Re-runs on construction and on every store refresh. Item panels are
/// cached by the dock, so this is the only way a panel opened before the
/// store's first pass learns about its PR.
fn sync(&mut self, cx: &mut Context<Self>) {
let loaded = self.store.read(cx).loaded;
let binding = {
let store = self.store.read(cx);
store.addr().and_then(|addr| {
store
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
.map(|root| {
let update = latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
let clone_urls = clone_urls_of(root)
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
.unwrap_or_default();
PrBinding {
description: root.content.clone(),
patch: pull_request_patch(root, store.patches.iter()),
tip,
base,
clone_urls,
addr: addr.clone(),
has_patch_link: root.tags.event_ids().next().is_some(),
}
})
})
};
let Some(binding) = binding else {
self.sync_missing(loaded, cx);
return;
};
if self.bound.as_ref() == Some(&binding) {
return;
}
self.bound = Some(binding.clone());
self.load_diff(binding, cx);
}
/// The store does not hold the root PR yet, or at all.
///
/// Loading until the first pass is applied, not found afterwards.
fn sync_missing(&mut self, loaded: bool, cx: &mut Context<Self>) {
self.bound = None;
if !loaded {
if !self.loading || self.error.is_some() {
self.loading = true;
self.error = None;
cx.notify();
}
return;
}
if self.error.as_deref() != Some(NOT_FOUND) {
self.loading = false;
self.error = Some(NOT_FOUND.into());
cx.notify();
}
}
/// Load the bound PR's changed files and commits.
///
/// Nostr-backed pull requests parse the patch series, git-backed ones fetch
/// the clone and diff the `merge-base..tip` range.
fn load_diff(&mut self, binding: PrBinding, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
self.description = binding.description.clone().into();
self.current_commit = binding.tip.clone().map(SharedString::from);
cx.notify();
let cache = GitStore::global(cx).cache().clone();
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
let store = self.store.read(cx);
self.load_generation = self.load_generation.wrapping_add(1);
let generation = self.load_generation;
let Some(root) = store
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
else {
self.loading = false;
self.error = Some("Pull request not found".into());
cx.notify();
return;
let PrBinding {
patch,
tip,
base,
clone_urls,
addr,
has_patch_link,
..
} = binding;
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
let nostr_diff = cx
.background_spawn({
let patch = patch.clone();
async move { patch_diffs(&patch) }
})
.await;
let nostr_commits = cx
.background_spawn({
let patch = patch.clone();
async move { patch_commits(&patch) }
})
.await;
// PRs without patch events, e.g. published by ngit, carry their changes in git.
// Fetch the clone and diff the `merge-base..tip` range.
let use_nostr = match &nostr_diff {
Ok(diff) => has_patch_link || !diff.files.is_empty(),
Err(_) => true,
};
let update = latest_update(store.pull_requests.iter(), root);
let git = if use_nostr {
None
} else {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
let base = base.clone();
let tip = tip.clone();
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
Some(
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf();
let clone_urls = clone_urls_of(root)
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()));
let tip =
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
(
root.content.clone(),
pull_request_patch(root, store.patches.iter()),
tip,
base,
clone_urls.unwrap_or_default(),
store.addr().clone(),
root.tags.event_ids().next().is_some(),
)
};
let base = match base {
Some(base) => base,
// No `merge-base` tag. Use the merge base of the tip and the default branch.
None => {
let head = repo
.head_id()
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
repo.merge_base(tip_id, head)?.to_string()
}
};
self.description = description.into();
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
let commits =
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
let task: gpui::Task<Result<(), anyhow::Error>> =
cx.spawn_in(window, async move |this, cx| {
let nostr_diff = cx
.background_spawn({
let patch = patch.clone();
async move { patch_diffs(&patch) }
Ok::<_, anyhow::Error>((diff, commits, workdir))
})
.await;
.await,
)
};
let nostr_commits = cx
.background_spawn({
let patch = patch.clone();
async move { patch_commits(&patch) }
})
.await;
let (diff, commits, worktree) = match git {
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
Some(Err(error)) => (Err(error), Vec::new(), None),
None => (nostr_diff, nostr_commits, None),
};
// PRs without patch events, e.g. published by ngit, carry their changes in git.
// Fetch the clone and diff the `merge-base..tip` range.
let use_nostr = match &nostr_diff {
Ok(diff) => has_patch_link || !diff.files.is_empty(),
Err(_) => true,
};
this.update(cx, |this, cx| {
// A newer binding superseded this load.
if this.load_generation != generation {
return;
}
let git = if use_nostr {
None
} else {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
let base = merge_base.clone();
let tip = current_commit.clone();
this.loading = false;
this.worktree = worktree;
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
this.commits = commits;
Some(
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf();
let tip = tip
.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
let base = match base {
Some(base) => base,
// No `merge-base` tag. Use the merge base of the tip and the default branch.
None => {
let head = repo
.head_id()
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
repo.merge_base(tip_id, head)?.to_string()
}
};
let diff =
signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
let commits =
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
Ok::<_, anyhow::Error>((diff, commits, workdir))
})
.await,
)
};
let (diff, commits, worktree) = match git {
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
Some(Err(error)) => (Err(error), Vec::new(), None),
None => (nostr_diff, nostr_commits, None),
};
this.update_in(cx, |this, _window, cx| {
this.loading = false;
this.worktree = worktree;
this.current_commit = current_commit.map(SharedString::from);
this.commit_item_sizes =
Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
this.commits = commits;
match diff {
Ok(diff) => {
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
}
match diff {
Ok(diff) => {
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
cx.notify();
})?;
Ok(())
});
Ok(())
});
task.detach();
}
@@ -435,9 +510,6 @@ impl PullRequestDetailView {
.into_any_element()
}
/// Full-height Commits tab.
///
/// Every commit of the patch series, or a status message while loading or empty.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
@@ -485,9 +557,6 @@ impl PullRequestDetailView {
.into_any_element()
}
/// One row of the commits tab, id, summary, author and time.
///
/// Clicking a row opens the commit's diff in the bottom dock.
fn render_commit_row(
&self,
ix: usize,
@@ -540,7 +609,6 @@ impl PullRequestDetailView {
.into_any_element()
}
/// Always-visible header with a status badge and title, like the issue panel.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let current_commit = self.current_commit.clone();
let (title, status, branch, author) = {
@@ -642,7 +710,6 @@ impl PullRequestDetailView {
}
}
/// Open the update pull request dialog.
fn open_update_pull_request_dialog(
store: Entity<RepoStore>,
root: Event,
@@ -703,7 +770,6 @@ fn open_update_pull_request_dialog(
});
}
/// The `c` tag of a PR event, the commit the proposal points at.
/// One-line commit metadata for the commits list.
///
/// Author and relative time, whichever is available.
@@ -761,26 +827,3 @@ impl Render for PullRequestDetailView {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
#[test]
fn commit_meta_combines_author_and_time() {
let commit = |author: &str, time: i64| FileCommit {
id: COMMIT_HEX.into(),
summary: "summary".into(),
description: None,
author: author.into(),
time,
};
assert_eq!(commit_meta(&commit("Alice", 0)), "Alice");
assert_eq!(commit_meta(&commit("", 0)), "");
assert!(!commit_meta(&commit("", 1_000_000)).is_empty());
assert!(!commit_meta(&commit("Alice", 1_000_000)).is_empty());
}
}
+69 -77
View File
@@ -4,8 +4,8 @@ use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, size,
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
};
use gpui_base::Button as BaseButton;
use gpui_component::alert::Alert;
@@ -27,26 +27,18 @@ use self::new::open_new_pull_panel;
use super::send_patch::open_send_patch_panel;
use crate::views::repo::RepoAction;
/// Height of one pull request row in the virtual list.
const ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullRequestFilter {
/// Every pull request, regardless of status.
All,
/// Pull requests whose resolved status is [`RepoStatus::Open`].
Open,
/// Pull requests whose resolved status is [`RepoStatus::Closed`].
Closed,
/// Pull requests whose resolved status is [`RepoStatus::Draft`].
Draft,
/// Pull requests whose resolved status is [`RepoStatus::Applied`].
Merged,
}
impl PullRequestFilter {
/// Whether a pull request with `status` is included by this filter.
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
@@ -60,37 +52,39 @@ impl PullRequestFilter {
pub struct PullRequestsView {
focus_handle: FocusHandle,
/// Dock area the detail panels are added to.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the pull requests and their statuses.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Filter selected in the header filter buttons.
filter: PullRequestFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// The filtered pull request count [`Self::item_sizes`] was built for.
pr_len: usize,
/// 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),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>,
/// Virtual list state of the pull requests list.
// A filter change notifies even when the visible rows are unchanged,
// e.g. switching between two empty filters.
synced_filter: PullRequestFilter,
scroll_handle: VirtualListScrollHandle,
_subscription: Subscription,
}
impl PullRequestsView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
_window: &mut Window,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild(cx);
});
cx.defer_in(window, |this, _window, cx| {
this.rebuild(cx);
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -98,15 +92,61 @@ impl PullRequestsView {
repo_name,
filter: PullRequestFilter::Open,
item_sizes: Rc::new(Vec::new()),
pr_len: 0,
visible_prs: Vec::new(),
counts: (0, 0, 0, 0, 0),
cache_key: None,
synced_filter: PullRequestFilter::Open,
scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
}
}
/// Open the detail panel of `pr_id` in the dock area.
fn rebuild(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
let (visible_prs, counts) = {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
let visible_prs: Vec<usize> = 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)
};
let filter_changed = self.synced_filter != filter;
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
return;
}
self.synced_filter = filter;
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); visible_prs.len()]);
self.visible_prs = visible_prs;
self.counts = counts;
cx.notify();
}
fn open_pull_request_detail(
&mut self,
pr_id: EventId,
@@ -132,9 +172,6 @@ impl PullRequestsView {
});
}
/// Render one row of the pull request list.
///
/// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let pr = &self.store.read(cx).pull_requests[pr_ix];
let pr_id = pr.id;
@@ -199,7 +236,6 @@ impl PullRequestsView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild.
let (total, open, closed, draft, merged) = self.counts;
h_flex()
@@ -220,7 +256,7 @@ impl PullRequestsView {
.selected(self.filter == PullRequestFilter::All)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::All;
cx.notify();
this.rebuild(cx);
})),
)
.child(
@@ -230,7 +266,7 @@ impl PullRequestsView {
.selected(self.filter == PullRequestFilter::Open)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Open;
cx.notify();
this.rebuild(cx);
})),
)
.child(
@@ -240,7 +276,7 @@ impl PullRequestsView {
.selected(self.filter == PullRequestFilter::Closed)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Closed;
cx.notify();
this.rebuild(cx);
})),
)
.child(
@@ -250,7 +286,7 @@ impl PullRequestsView {
.selected(self.filter == PullRequestFilter::Draft)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Draft;
cx.notify();
this.rebuild(cx);
})),
)
.child(
@@ -260,7 +296,7 @@ impl PullRequestsView {
.selected(self.filter == PullRequestFilter::Merged)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Merged;
cx.notify();
this.rebuild(cx);
})),
),
)
@@ -333,55 +369,11 @@ impl Focusable for PullRequestsView {
impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Rows and counts are rebuilt only when the store refreshed or filter changed.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
self.visible_prs = 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();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_prs.len();
// The virtual list's item count comes from `item_sizes`.
// Rebuild it whenever the filtered pull request count changes.
if count != self.pr_len {
self.pr_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]);
}
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone();
// Non-fatal warnings and errors of the last action, like creating or updating a PR.
// Shown as dismissible banners above the list.
let (last_error, last_warning) = {
let store = self.store.read(cx);
(store.last_error.clone(), store.last_warning.clone())
+45 -78
View File
@@ -27,25 +27,19 @@ use signed_git::{
worktree_commit_range_commits, worktree_commit_range_diff,
};
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
use signed_ui::{CountBadge, placeholder};
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
use crate::views::commit_diff::{CommitDiffView, DiffPane};
use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger};
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
/// The new pull request panel of a repository.
pub struct NewPullRequestView {
focus_handle: FocusHandle,
/// Dock area the panel lives in, commit diffs are opened there.
dock_area: WeakEntity<DockArea>,
/// Store of the target repository, source of the announced HEAD default.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// The user's local checkout.
repo_path: Option<PathBuf>,
/// Branches of the checkout, backing both selectors in checkout mode.
/// Backs both selectors in checkout mode.
branches: Vec<SharedString>,
/// Fork-backed compare state.
fork: Option<ForkCompare>,
/// Selected base branch, the PR target, stored as a short name.
base: SharedString,
@@ -53,33 +47,27 @@ pub struct NewPullRequestView {
compare: SharedString,
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// Title input, required.
subject: Entity<InputState>,
/// Description input, optional.
description: Entity<TextareaState>,
/// Merge base of the selected branches, `None` until the compare loads.
merge_base: Option<String>,
/// Commits in `merge_base..compare`, newest first.
commits: Option<Vec<signed_git::FileCommit>>,
/// The compare is being computed.
loading: bool,
/// Error of the last compare or submit attempt.
error: Option<SharedString>,
/// A submit, patch generation and publish, is in flight.
submitting: bool,
/// Bumped on every branch switch, stale compare results are discarded.
compare_generation: u64,
/// Active tab, 0 = Files and 1 = Commits.
/// 0 = Files, 1 = Commits.
active_tab: usize,
/// The compare diff, the Files tab body.
pane: Entity<DiffPane>,
/// Virtual list state of the Commits tab.
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
_subscriptions: Vec<Subscription>,
}
/// A fork-backed compare.
struct ForkCompare {
/// Fork announcement the compare branch is imported from.
announcement: Announcement,
@@ -90,20 +78,15 @@ struct ForkCompare {
}
impl ForkCompare {
/// The full ref of the base branch `name` in the mirror.
fn base_ref(name: &str) -> String {
format!("refs/remotes/origin/{name}")
}
/// The full ref of the compare branch `name` in the mirror.
fn compare_ref(&self, name: &str) -> String {
format!("refs/fork/{}/{}", self.namespace, name)
}
}
/// The display name of an announcement.
///
/// Its human-readable name, falling back to the repository id.
fn fork_display_name(announcement: &Announcement) -> SharedString {
announcement
.name
@@ -112,7 +95,6 @@ fn fork_display_name(announcement: &Announcement) -> SharedString {
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
}
/// A short label of a fork's owner for the source picker, a hex prefix.
fn shorten_owner(owner: &PublicKey) -> String {
let hex = owner.to_hex();
hex.chars().take(10).collect()
@@ -131,9 +113,6 @@ fn truncate_label(label: &str) -> SharedString {
SharedString::from(label)
}
/// The compare-source menu entry of one local checkout folder.
///
/// Applies the folder directly, no picker.
fn checkout_source_item(
view: WeakEntity<NewPullRequestView>,
path: PathBuf,
@@ -162,7 +141,6 @@ fn checkout_source_item(
})
}
/// The compare-source menu entry prompting for an arbitrary folder.
fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuItem {
PopupMenuItem::element(move |_window, cx| {
source_row(
@@ -179,9 +157,6 @@ fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuI
})
}
/// The compare-source menu entry of one announced fork.
///
/// Imports its branches into the target's mirror and switches the panel to fork mode.
fn fork_source_item(
view: WeakEntity<NewPullRequestView>,
announcement: Announcement,
@@ -207,7 +182,6 @@ fn fork_source_item(
})
}
/// One row of the compare-source menu, icon, title and a muted subtitle.
fn source_row<T>(icon: impl Into<Icon>, title: T, subtitle: T, cx: &App) -> AnyElement
where
T: Into<SharedString>,
@@ -305,7 +279,24 @@ impl NewPullRequestView {
),
];
let mut view = Self {
cx.defer_in(window, |this, window, cx| {
let Some(addr) = this.store.read(cx).addr().cloned() else {
return;
};
let Some(path) = CheckoutsStore::global(cx)
.read(cx)
.associations_of(&addr)
.into_iter()
.next()
else {
return;
};
this.apply_folder_path(path, window, cx);
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
@@ -330,23 +321,9 @@ impl NewPullRequestView {
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
_subscriptions: subscriptions,
};
// Prefill with the store's freshest associated checkout, no folder dialog.
let addr = view.store.read(cx).addr().clone();
if let Some(path) = CheckoutsStore::global(cx)
.read(cx)
.associations_of(&addr)
.into_iter()
.next()
{
view.apply_folder_path(path, window, cx);
}
view
}
/// Whether a compare source, a checkout or a fork, is applied.
fn has_source(&self) -> bool {
self.repo_path.is_some() || self.fork.is_some()
}
@@ -383,7 +360,6 @@ impl NewPullRequestView {
}
}
/// Prompt for a local checkout.
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
@@ -414,9 +390,7 @@ impl NewPullRequestView {
task.detach();
}
/// Apply `path` as the local checkout, no picker.
///
/// Branches and current branch are read off the UI thread, then applied.
/// Branches and the current branch are read off the UI thread, then applied.
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let path = path.to_string_lossy().to_string();
@@ -445,7 +419,6 @@ impl NewPullRequestView {
task.detach();
}
/// Apply a picked checkout, filling the selectors and loading the compare.
fn apply_checkout(
&mut self,
path: String,
@@ -496,11 +469,12 @@ impl NewPullRequestView {
// Remember this folder as a checkout of the target repository.
// The next panel pre-fills it.
let addr = self.store.read(cx).addr().clone();
let checkout_store = CheckoutsStore::global(cx);
checkout_store.update(cx, |store, cx| {
store.record(PathBuf::from(&path), addr, cx);
});
if let Some(addr) = self.store.read(cx).addr().cloned() {
let checkout_store = CheckoutsStore::global(cx);
checkout_store.update(cx, |store, cx| {
store.record(PathBuf::from(&path), addr, cx);
});
}
let branches = self.branches.clone();
let base = SharedString::from(base.clone());
@@ -522,20 +496,21 @@ impl NewPullRequestView {
self.reload_compare(window, cx);
}
/// The base repository of the panel, its address and announced EUC.
///
/// Used to find fork candidates.
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
/// Used to find fork candidates. `None` while the repository is not announced.
fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option<String>)> {
let store = self.store.read(cx);
let addr = store.addr()?.clone();
let euc = store.announcement.as_ref().and_then(|a| a.euc.clone());
(store.addr().clone(), euc)
Some((addr, euc))
}
/// Announced forks of the target repository a compare can use, own first.
///
/// Re-read whenever the picker opens.
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
let (base, euc) = self.base_repo(cx);
let Some((base, euc)) = self.base_repo(cx) else {
return Vec::new();
};
let user = Backend::global(cx).read(cx).current_user();
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
fork_candidates(&announcements, &base, euc.as_deref(), user)
@@ -544,7 +519,6 @@ impl NewPullRequestView {
.collect()
}
/// Compare against an announced fork.
fn choose_fork(
&mut self,
announcement: Announcement,
@@ -556,7 +530,9 @@ impl NewPullRequestView {
.as_ref()
.is_some_and(|fork| fork.announcement.addr() == announcement.addr());
let (base, _euc) = self.base_repo(cx);
let Some((base, _euc)) = self.base_repo(cx) else {
return;
};
let cache = GitStore::global(cx).cache().clone();
let mirror_path = cache.repo_path(&base);
let namespace = fork_namespace(&announcement);
@@ -597,9 +573,6 @@ impl NewPullRequestView {
let clone_urls = clone_urls.clone();
let mirror_path = mirror_path.clone();
async move {
// The fork and base must share history for a merge-base to exist.
// The target's mirror is the object store both sides land in.
// `ensure_clone` fetches `origin` when the mirror already exists.
cache.ensure_clone(&base, &base_clone_urls)?;
// Prune stale imports of any fork.
@@ -668,7 +641,6 @@ impl NewPullRequestView {
task.detach();
}
/// Apply an imported fork, filling the selectors and loading the compare.
#[allow(clippy::too_many_arguments)]
fn apply_fork(
&mut self,
@@ -773,7 +745,6 @@ impl NewPullRequestView {
self.reload_compare(window, cx);
}
/// Recompute `merge_base..compare` of the selected branches on a background task.
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.work_path() else {
return;
@@ -867,7 +838,6 @@ impl NewPullRequestView {
task.detach();
}
/// Publish the pull request.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting || self.loading {
return;
@@ -949,7 +919,6 @@ impl NewPullRequestView {
task.detach();
}
/// Open the diff of `commit_id`, from the Commits tab, in a new panel.
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.work_path() else {
return;
@@ -1107,7 +1076,6 @@ impl NewPullRequestView {
.into_any_element()
}
/// The source picker's trigger, a truncated label of the applied source.
fn source_trigger(&self) -> SharedString {
match &self.fork {
Some(fork) => truncate_label(&fork_display_name(&fork.announcement)),
@@ -1118,14 +1086,17 @@ impl NewPullRequestView {
}
}
/// Build the compare-source menu.
fn source_menu(
&self,
cx: &Context<Self>,
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
let view = cx.entity().downgrade();
let addr = self.store.read(cx).addr().clone();
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
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())
@@ -1171,7 +1142,6 @@ impl NewPullRequestView {
}
}
/// The title and description inputs.
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
v_flex()
.px_4()
@@ -1182,7 +1152,6 @@ impl NewPullRequestView {
.into_any_element()
}
/// The Files/Commits tab bar, mirroring the repository panel's.
fn render_tabs(&self, cx: &mut Context<Self>) -> 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());
@@ -1254,7 +1223,6 @@ impl NewPullRequestView {
.into_any_element()
}
/// The active tab's body.
fn render_content(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
@@ -1340,7 +1308,6 @@ impl NewPullRequestView {
}
}
/// Open the new pull request panel in the center dock.
pub(crate) fn open_new_pull_panel(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
-12
View File
@@ -7,7 +7,6 @@ use signed_core::Announcement;
use signed_state::ProfileStore;
use signed_ui::{UserAvatar, middle_truncate};
/// Open the About dialog showing every field of the announcement event.
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| {
let announcement = announcement.clone();
@@ -21,7 +20,6 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
});
}
/// The announcement's fields as labeled rows.
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new();
@@ -116,7 +114,6 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
v_flex().gap_3().w_full().children(rows).into_any_element()
}
/// One info row with a small muted label above the value.
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
@@ -132,7 +129,6 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
.into_any_element()
}
/// Plain text value, wrapping within the dialog.
fn text<T>(value: T) -> AnyElement
where
T: Into<SharedString>,
@@ -147,7 +143,6 @@ where
.into_any_element()
}
/// A mono-spaced value with a copy button, for hex identifiers.
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
h_flex()
.gap_2()
@@ -164,10 +159,6 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
.into_any_element()
}
/// One row per maintainer with avatar and display name.
/// The display name falls back to a shortened npub.
///
/// A copy button copies the full pubkey.
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx);
v_flex()
@@ -197,9 +188,6 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
.into_any_element()
}
/// One row per item of a multi-value tag.
///
/// The value is truncated to a single line, with a copy button for the full value.
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex()
.gap_2()
+16 -203
View File
@@ -1,208 +1,27 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use dock::{DockArea, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{App, Context, Entity, WeakEntity, Window};
use gpui::{App, Entity, WeakEntity, Window};
use gpui_base::dock::PanelView;
use nostr::prelude::EventId;
use signed_core::{Announcement, filters};
use signed_state::{Backend, RepoListStore, RepoStore};
use signed_core::{Announcement, RepoAddr};
use signed_state::RepoStore;
use super::RepoDetailView;
use crate::views::issues::IssuesView;
use crate::views::issues::detail::IssueDetailView;
use crate::views::pull_requests::PullRequestsView;
use crate::views::pull_requests::detail::PullRequestDetailView;
use crate::views::repo::init_dialog;
impl RepoDetailView {
/// Re-push the repository's refs to its announced grasp servers.
pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
self.error = None;
cx.notify();
store
.update(cx, |store, cx| store.push_repository(cx))
.detach();
}
/// Push the unpushed commits of the local checkout at `path`.
pub(super) fn push_unpushed_checkout(
&mut self,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(store) = self.store.clone() else {
return;
};
if store.read(cx).pushing {
return;
}
self.error = None;
cx.notify();
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
// The store owns the push, its busy flag and error reporting.
let push = this.update_in(cx, |_this, _window, cx| {
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
})?;
// The remote moved, refresh the mirror browsing.
// Failures already surfaced in the store's error banner.
if let Ok(()) = push.await {
this.update_in(cx, |this, window, cx| {
this.load_repo(window, cx);
})?;
}
Ok(())
});
task.detach();
}
/// Delete the repository from nostr, announcement, state and activity.
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
store
.update(cx, |store, cx| store.delete_repository(cx))
.detach();
}
/// Open the issues list panel in the dock area.
pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
/// Open the pull requests list panel in the dock area.
pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
/// Open the upstream repository, the `u` tag of this fork's announcement.
/// The upstream announcement may not be in the local database yet.
/// Subscribe for it and open the panel as soon as it lands.
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_upstream.is_some() {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else {
return;
};
if let Some(found) = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == addr)
.cloned()
{
open_repo_panel(&self.dock_area, &found, window, &mut *cx);
return;
}
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
});
self.pending_upstream = Some(addr);
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
for _ in 0..60 {
cx.background_executor()
.timer(Duration::from_millis(250))
.await;
let opened = this.update_in(cx, |this, window, cx| {
let Some(addr) = this.pending_upstream.clone() else {
return true;
};
let found = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == addr)
.cloned();
match found {
Some(found) => {
this.pending_upstream = None;
open_repo_panel(&this.dock_area, &found, window, &mut *cx);
true
}
None => false,
}
})?;
if opened {
return Ok(());
}
}
this.update(cx, |this, _cx| this.pending_upstream = None)?;
Ok(())
});
task.detach();
}
/// Open the dialog guiding the user through publishing the local repository to NIP-34.
pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(local_path) = self.local_path.clone() else {
return;
};
let view = cx.entity().downgrade();
init_dialog::open(local_path, view, window, cx);
}
}
/// Open `announcement` as a repository panel in the dock's center.
/// Open repository as a panel in the dock's center.
pub(crate) fn open_repo_panel(
dock_area: &WeakEntity<DockArea>,
announcement: &Announcement,
addr: &RepoAddr,
hint: Option<&Announcement>,
window: &mut Window,
cx: &mut App,
) -> Entity<RepoDetailView> {
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
let detail = cx
.new(|cx| RepoDetailView::new(dock_area.clone(), addr.clone(), hint.cloned(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
@@ -213,29 +32,23 @@ pub(crate) fn open_repo_panel(
detail
}
/// The nostr store of `announcement`'s repository, without opening a repository panel.
fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity<RepoStore> {
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx))
/// The nostr store of `addr`'s repository, without opening a repository panel.
fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity<RepoStore> {
cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx))
}
/// An item of a repository to open from outside its detail panel.
/// A patch has no detail view in Signed, so it opens nothing.
pub(crate) enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center.
///
/// The repository store is built here, not taken from a `RepoDetailView`, so the
/// item panel is the only panel docked.
///
/// A patch opens nothing: patches are only consumed inside a pull request's
/// detail panel, and have no panel of their own.
/// The repository store is built here.
pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>,
announcement: &Announcement,
addr: &RepoAddr,
hint: Option<&Announcement>,
item: RepoItem,
window: &mut Window,
cx: &mut App,
@@ -243,11 +56,11 @@ pub(crate) fn open_repo_item(
let panel: Arc<dyn PanelView> =
match item {
RepoItem::Issue(issue_id) => {
let store = repo_store(announcement, cx);
let store = repo_store(addr, hint, cx);
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
}
RepoItem::PullRequest(pr_id) => {
let store = repo_store(announcement, cx);
let store = repo_store(addr, hint, cx);
panel_handle(cx.new(|cx| {
PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)
}))
+33 -314
View File
@@ -1,327 +1,46 @@
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, Context, SharedString, div, transparent_white};
use gpui_base::Disableable;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::{ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, h_flex};
use signed_core::RepoStatus;
use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
use std::collections::HashSet;
use std::path::PathBuf;
use super::RepoDetailView;
use crate::views::pull_requests::new::open_new_pull_panel;
use signed_state::CheckoutStatus;
impl RepoDetailView {
/// The first checkout ready for a pull request on this repository.
/// Not covered by an open PR of the signed-in user.
/// Not dismissed in this panel.
/// The repository's own checkouts are not suggested here.
/// Their work is pushed, see [`Self::push_suggestion`].
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.as_ref()?;
let addr = store.read(cx).addr().clone();
let user = Backend::global(cx).read(cx).current_user()?;
if store.read(cx).is_author(&user) {
return None;
}
#[derive(Default)]
pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>,
ready_requested: bool,
/// Re-requested only when the announced HEAD or the base default changes.
ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>,
}
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr);
'status: for status in statuses {
if self
.banner_dismissed
.contains(&(status.path.clone(), status.branch.clone()))
{
continue;
}
let store = store.read(cx);
for pr in &store.pull_requests {
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
{
continue 'status;
}
}
return Some(status);
}
None
impl Banners {
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
self.dismissed
.contains(&(status.path.clone(), status.branch.clone()))
}
/// The first checkout of this owned repository with unpushed commits.
///
/// Not dismissed in this panel.
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let entity = self.store.as_ref()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !entity.read(cx).is_author(&user) {
return None;
}
let addr = entity.read(cx).addr().clone();
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr);
statuses.into_iter().find(|status| {
!self
.banner_dismissed
.contains(&(status.path.clone(), status.branch.clone()))
})
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
self.dismissed
.insert((status.path.clone(), status.branch.clone()));
}
/// The ready-to-push banner of an owned repository.
///
/// A local checkout has unpushed commits, with a Push action and a dismiss control.
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?;
let key = (status.path.clone(), status.branch.clone());
let path = status.path.clone();
// The push busy flag lives on the store; it disables the banner's triggers.
let pushing = self
.store
.as_ref()
.is_some_and(|store| store.read(cx).pushing);
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", status.ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.branch),
)
.child("has")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ready to push"),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("push-checkout-banner")
.icon(IconName::ArrowUp)
.label("Push")
.small()
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
)
.child(
Button::new("close-repo")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.disabled(pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
cx.notify();
})),
),
)
.into_any_element(),
)
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head)
}
/// Warning after a push that only some grasp servers accepted.
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.as_ref()?;
let store = store.read(cx);
let warning = store.last_push_warning.clone()?;
let pushing = store.pushing;
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_start()
.justify_between()
.bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08))
.child(
h_flex()
.gap_2()
.min_w_0()
.flex_1()
.items_start()
.child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0())
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_color(cx.theme().warning)
.child(SharedString::from(warning)),
),
)
.child(
h_flex()
.gap_1()
.flex_shrink_0()
.child(
Button::new("republish-after-partial-push")
.icon(CustomIconName::Init)
.label("Republish")
.small()
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(|this, _event, window, cx| {
this.push_repository(window, cx);
})),
)
.child(
Button::new("dismiss-push-warning")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.disabled(pushing)
.on_click(cx.listener(|this, _ev, _window, cx| {
if let Some(store) = this.store.clone() {
store.update(cx, |store, _| {
store.last_push_warning = None;
});
}
cx.notify();
})),
),
)
.into_any_element(),
)
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
self.ready_requested = true;
self.ready_head = head;
}
/// The ready-to-contribute banner of the repository panel.
pub(super) fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.ready_suggestion(cx)?;
let key = (status.path.clone(), status.branch.clone());
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", status.ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.branch),
)
.child("is")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ahead of")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.base),
),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("create-pr-from-banner")
.icon(IconName::Plus)
.label("Create")
.small()
.info()
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(store) = this.store.clone() {
open_new_pull_panel(
this.dock_area.clone(),
store,
window,
cx,
);
}
})),
)
.child(
Button::new("dismiss-ready-banner")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
cx.notify();
})),
),
)
.into_any_element(),
)
pub(super) fn set_statuses(
&mut self,
ready: Vec<CheckoutStatus>,
push: Vec<CheckoutStatus>,
) -> bool {
let changed = ready != self.ready_statuses || push != self.push_statuses;
self.ready_statuses = ready;
self.push_statuses = push;
changed
}
}
+367 -123
View File
@@ -1,8 +1,9 @@
use std::path::{Component, Path};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use anyhow::Error;
use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Editor, EditorState};
use gpui_component::list::ListItem;
@@ -10,76 +11,204 @@ use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState};
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
use signed_git::{FileCommit, WorktreeSnapshot};
use signed_ui::{placeholder, tree_row};
use super::RepoDetailView;
use crate::views::repo::helpers::{code_language, is_markdown_path};
use crate::views::tree::{TreeItemSeed, tree_items};
/// Width of the file explorer column.
const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps, a file count and a text byte count.
///
/// The oldest previews are evicted beyond the caps.
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
const MAX_PREVIEWED_FILES: usize = 32;
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
/// Preview state of a browsed file.
pub(super) enum FileContent {
/// Decodable text content.
enum FileContent {
Text(String),
/// Not valid UTF-8.
Binary,
/// Bigger than [`MAX_PREVIEW_BYTES`].
TooLarge,
/// Reading failed.
Failed(String),
}
/// A markdown document loaded into a persistent [`TextViewState`].
pub(super) struct MarkdownView {
/// Source path, `None` means the repository README.
pub(super) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>,
struct MarkdownView {
/// `None` means the repository README.
path: Option<SharedString>,
state: Entity<TextViewState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
/// A code file loaded into a persistent [`InputState`].
pub(super) struct CodeView {
struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
pub(super) state: Entity<EditorState>,
path: SharedString,
state: Entity<EditorState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
/// Hash of a preview's source text.
///
/// Two loads of the same document produce the same hash, so the persistent
/// markdown/editor state can be kept instead of rebuilt, which would re-parse
/// and flash the pane.
fn source_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
pub(super) struct RepoFilesView {
tree_state: Entity<TreeState>,
worktree: Option<PathBuf>,
worktree_paths: Vec<String>,
md: Option<MarkdownView>,
code: Option<CodeView>,
readme_name: Option<SharedString>,
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
file_order: VecDeque<String>,
preview_bytes: usize,
loading_files: HashSet<String>,
commits: HashMap<String, FileCommit>,
pending_commits: Vec<String>,
loading_commits: bool,
generation: u64,
tasks: Vec<Task<Result<(), Error>>>,
}
/// Spinner shown while a document is being loaded/parsed.
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
}
impl RepoFilesView {
pub(super) fn new(cx: &mut Context<Self>) -> Self {
Self {
tree_state: cx.new(|cx| TreeState::new(cx)),
worktree: None,
worktree_paths: Vec::new(),
md: None,
code: None,
readme_name: None,
selected_file: None,
files: HashMap::new(),
file_order: VecDeque::new(),
preview_bytes: 0,
loading_files: HashSet::new(),
commits: HashMap::new(),
pending_commits: Vec::new(),
loading_commits: false,
generation: 0,
tasks: Vec::new(),
}
}
pub(super) fn set_worktree(&mut self, path: PathBuf) {
self.worktree = Some(path);
}
pub(super) fn apply_entries(
&mut self,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) {
self.worktree_paths = paths;
self.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
}
/// Point the README pane at `path`/`bytes`, or clear it when absent.
///
/// Returns whether the pane changed.
pub(super) fn set_readme(
&mut self,
path: Option<PathBuf>,
bytes: Option<Vec<u8>>,
cx: &mut Context<Self>,
) -> bool {
let Some((path, bytes)) = path.zip(bytes) else {
let changed = self.readme_name.is_some() || self.md.is_some();
self.readme_name = None;
self.md = None;
return changed;
};
let name: SharedString = path.to_string_lossy().into();
let mut changed = self.readme_name.as_ref() != Some(&name);
self.readme_name = Some(name);
self.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
changed |= self.set_markdown(None, &text, cx);
}
changed
}
/// Drop every cached preview and the README, e.g. on a branch switch.
pub(super) fn clear_previews(&mut self) {
self.selected_file = None;
self.files.clear();
self.file_order.clear();
self.preview_bytes = 0;
self.loading_files.clear();
self.commits.clear();
self.pending_commits.clear();
self.loading_commits = false;
self.md = None;
self.code = None;
self.readme_name = None;
self.generation += 1;
}
/// Refresh after the mirror caught up with the remote.
///
/// Unlike a branch switch this keeps the selection and previews: it rebuilds
/// the tree, drops previews of files the refresh removed and re-renders the
/// README when it is on screen.
///
/// Returns whether the tree, a preview or the README changed.
pub(super) fn catch_up(
&mut self,
snapshot: &WorktreeSnapshot,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) -> bool {
let mut changed = false;
if paths != self.worktree_paths {
self.apply_entries(tree, paths, cx);
changed = true;
}
let present: HashSet<String> = snapshot
.entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
let mut previewed: Vec<String> = Vec::new();
previewed.extend(self.files.keys().cloned());
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
previewed.push(path.to_string());
}
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
previewed.push(path.to_string());
}
previewed.sort();
previewed.dedup();
for path in previewed {
if !present.contains(&path) {
self.drop_preview_of(&path);
changed = true;
}
}
if self.selected_file.is_none() {
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
}
changed
}
fn pane_title(&self) -> SharedString {
self.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into())
}
impl RepoDetailView {
/// One row of the file tree with icon and name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
@@ -96,8 +225,7 @@ impl RepoDetailView {
})
}
/// Left column showing the file tree.
pub(super) fn render_tree_column(
fn render_tree_column(
tree_state: Entity<TreeState>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
@@ -117,44 +245,12 @@ impl RepoDetailView {
)))
}
/// Right column, README, selected file preview or status text.
pub(super) fn render_content_column(
fn render_content_column(
&self,
pane_title: SharedString,
cx: &mut Context<Self>,
) -> impl IntoElement {
let loading = self.loading;
let error = self.error.clone();
let selected_file = self.selected_file.clone();
let body: AnyElement = if loading {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(Spinner::new().small())
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Cloning repository..."),
)
.into_any_element()
} else if let Some(error) = error {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(error),
)
.into_any_element()
} else if let Some(path) = selected_file {
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
match self.files.get(path.as_ref()) {
Some(FileContent::Text(_)) => {
if is_markdown_path(path.as_ref()) {
@@ -175,7 +271,6 @@ impl RepoDetailView {
};
// Latest commit for the current pane, the selected file or the README.
// Computed after the body above, which needs `&mut self`.
let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()),
None => self
@@ -230,33 +325,33 @@ impl RepoDetailView {
.child(div().id("repo-content").flex_1().min_h_0().child(body))
}
/// Load `text` into the persistent markdown TextView state.
pub(super) fn set_markdown(
fn set_markdown(
&mut self,
path: Option<SharedString>,
text: &str,
cx: &mut Context<Self>,
) {
) -> bool {
let hash = source_hash(text);
if let Some(md) = &self.md
&& md.path == path
&& md.source_hash == hash
{
return;
return false;
}
let state = cx.new(|cx| TextViewState::markdown("", cx));
state.update(cx, |state, cx| state.push_str(text, cx));
self.md = Some(MarkdownView {
path,
state,
source_hash: hash,
});
true
}
/// The persistent markdown TextView for `path`, where `None` is the README.
///
/// Shows a spinner while the document is being loaded or parsed.
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
let Some(md) = &self.md else {
return preview_spinner();
@@ -279,10 +374,7 @@ impl RepoDetailView {
.into_any_element()
}
/// Load `text` into the persistent code editor state for `path`.
///
/// Code editor mode makes the Input render it read-only and highlighted.
pub(super) fn set_code(
fn set_code(
&mut self,
path: SharedString,
text: &str,
@@ -290,6 +382,7 @@ impl RepoDetailView {
cx: &mut Context<Self>,
) {
let hash = source_hash(text);
if let Some(code) = &self.code
&& code.path == path
&& code.source_hash == hash
@@ -305,6 +398,7 @@ impl RepoDetailView {
.line_number(true)
.folding(true)
});
self.code = Some(CodeView {
path,
state,
@@ -312,7 +406,6 @@ impl RepoDetailView {
});
}
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else {
return preview_spinner();
@@ -329,17 +422,11 @@ impl RepoDetailView {
.text_sm()
.into_any_element()
}
}
impl RepoDetailView {
/// Preview the file at `path`, relative to the worktree root.
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
if self.files.contains_key(path) {
// The file is cached, but the markdown or code state may hold a different file.
// Re-point it at this one, the parse runs on a background task either way.
// Without this, the pane would show a spinner forever.
if let Some(FileContent::Text(text)) = self.files.get(path) {
let text = text.clone();
if is_markdown_path(path) {
@@ -358,8 +445,6 @@ impl RepoDetailView {
return;
}
// Paths come from our own tree walk, but never trust them.
// Refuse anything that could escape the worktree.
let rel = Path::new(path);
let unsafe_path = rel.is_absolute()
|| rel.components().any(|c| {
@@ -381,27 +466,28 @@ impl RepoDetailView {
let path = path.to_string();
self.load_commit(&path, cx);
let generation = self.ref_generation;
let generation = self.generation;
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let path_for_read = path.clone();
let content = cx
.background_spawn(async move {
let full = worktree.join(&path_for_read);
// Refuse oversized files before reading them.
// Reading a multi-gigabyte file just to classify it is wasteful.
// It would burn disk and memory bandwidth.
let metadata = match std::fs::metadata(&full) {
Ok(metadata) => metadata,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
return Ok(FileContent::TooLarge);
}
let bytes = match std::fs::read(&full) {
Ok(bytes) => bytes,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
match String::from_utf8(bytes) {
Ok(text) => Ok(FileContent::Text(text)),
Err(_) => Ok(FileContent::Binary),
@@ -410,15 +496,13 @@ impl RepoDetailView {
.await;
this.update_in(cx, |this, window, cx| {
// The worktree was switched while this file was reading.
// The result belongs to the previous branch.
// Clear the in-flight marker either way.
// Otherwise the path could never be loaded again.
if generation != this.ref_generation {
if generation != this.generation {
this.loading_files.remove(&path);
return;
}
this.loading_files.remove(&path);
match content {
Ok(kind) => {
if let FileContent::Text(text) = &kind {
@@ -452,46 +536,49 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
/// Drop the cached preview, editor and commit state of `path`.
pub(super) fn drop_preview_of(&mut self, path: &str) {
fn drop_preview_of(&mut self, path: &str) {
if let Some(FileContent::Text(text)) = self.files.remove(path) {
self.preview_bytes -= text.len();
}
self.commits.remove(path);
if self.selected_file.as_deref() == Some(path) {
self.selected_file = None;
}
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
self.md = None;
}
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
self.code = None;
}
}
/// Drop the oldest previews beyond the cache caps.
/// Keep the currently selected file.
/// An evicted file's parsed editor state drops with its entry.
/// Re-opening it re-parses on a background task.
fn evict_previews(&mut self) {
while (self.files.len() > MAX_PREVIEWED_FILES
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
&& self.file_order.len() > 1
{
let path = self.file_order.pop_front().expect("non-empty");
if Some(path.as_str()) == self.selected_file.as_deref() {
self.file_order.push_back(path);
continue;
}
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
self.preview_bytes -= text.len();
}
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
self.md = None;
}
if self
.code
.as_ref()
@@ -499,7 +586,164 @@ impl RepoDetailView {
{
self.code = None;
}
self.commits.remove(&path);
}
}
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let generation = self.generation;
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(
async move { signed_git::worktree_last_commits(&worktree, &rels) },
)
.await;
this.update(cx, |this, cx| {
this.loading_commits = false;
if generation == this.generation
&& let Ok(found) = result
{
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
}
impl Render for RepoFilesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
let pane_title = self.pane_title();
h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(Self::render_tree_column(tree_state, view, cx))
.child(self.render_content_column(pane_title, cx))
}
}
fn source_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
}
/// The markdown fence language for a file path, or `None` for plain text.
fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
// Some common files are recognized by name rather than extension.
match name {
"Makefile" | "makefile" => return Some("make"),
"CMakeLists.txt" => return Some("cmake"),
_ => {}
}
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => "rust",
"toml" => "toml",
"json" | "jsonc" => "json",
"py" => "python",
"js" | "mjs" | "cjs" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" | "jsx" => "tsx",
"go" => "go",
"c" | "h" => "c",
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
"cs" => "csharp",
"java" => "java",
"kt" | "kts" | "ktm" => "kotlin",
"swift" => "swift",
"php" | "phtml" => "php",
"rb" => "ruby",
"sh" | "bash" | "zsh" => "bash",
"yml" | "yaml" => "yaml",
"css" | "scss" | "sass" => "css",
"html" | "htm" => "html",
"lua" => "lua",
"sql" => "sql",
"proto" | "protobuf" => "proto",
"cmake" => "cmake",
"zig" => "zig",
"ex" | "exs" => "elixir",
"graphql" | "gql" => "graphql",
"diff" | "patch" => "diff",
"svelte" => "svelte",
"astro" => "astro",
"scala" => "scala",
_ => return None,
})
}
/// Whether a file path has a markdown extension.
fn is_markdown_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"md" | "markdown" | "mdown" | "mkdn"
)
})
}
-728
View File
@@ -1,728 +0,0 @@
use std::collections::HashSet;
use std::rc::Rc;
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, relative};
use gpui_base::{Button as BaseButton, Disableable, Popover};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::Combobox;
use gpui_component::menu::DropdownMenu;
use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
};
use nostr::prelude::{RelayUrl, ToBech32};
use signed_core::Announcement;
use signed_state::{Backend, ProfileStore, RepoListStore};
use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row};
use super::{RepoAction, RepoDetailView};
use crate::views::issues::open_new_issue_dialog;
use crate::views::pull_requests::new::open_new_pull_panel;
use crate::views::repo::about::open_about_dialog;
use crate::views::repo::helpers::{ShareTargets, ref_selector_trigger};
use crate::views::send_patch::open_send_patch_panel;
impl RepoDetailView {
/// The NIP-34 header, actions and issues/PR counts.
/// Or the local header with an Init button for an unpublished repository.
pub(super) fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.local_path.is_some() {
return self.render_local_header(cx);
}
let Some(store_entity) = self.store.as_ref() else {
return div().into_any_element();
};
let store = store_entity.read(cx);
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
// Busy flags are owned by the store; observers re-render on their changes.
let pushing = store.pushing;
let cloning = store.cloning;
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
return div().into_any_element();
};
// Derived NIP-34 header data, share targets and clone commands.
// Rebuilt per frame: two bech32 encodes and a couple of format strings.
let nip05 = ProfileStore::global(cx)
.read(cx)
.get(&source.owner)
.metadata()
.nip05
.clone()
.filter(|nip05| !nip05.trim().is_empty());
let announcement = Rc::new(source.clone());
let share = Rc::new(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 = Rc::new(announcement.clone_urls());
let name = self.display_name(cx);
let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
v_flex()
.on_action(
cx.listener(|this, action: &RepoAction, window, cx| match action {
RepoAction::NewIssue => {
if let Some(store) = this.store.clone() {
open_new_issue_dialog(store, window, cx);
}
}
RepoAction::NewPR => {
if let Some(store) = this.store.clone() {
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
}
}
RepoAction::SendPatch => {
if let Some(store) = this.store.clone() {
open_send_patch_panel(this.dock_area.clone(), store, window, cx);
}
}
RepoAction::About => {
if let Some(announcement) = this.announcement(cx) {
open_about_dialog(announcement.clone(), window, cx);
}
}
RepoAction::Push => this.push_repository(window, cx),
RepoAction::Delete => this.delete_repository(window, cx),
}),
)
.p_4()
.w_full()
.gap_8()
.border_b_1()
.border_color(cx.theme().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()
.min_h_8()
.font_semibold()
.child(avatar.size_6())
.child(name),
)
.child(
div()
.min_w_0()
.text_sm()
.text_color(cx.theme().muted_foreground)
.line_clamp(2)
.line_height(relative(1.25))
.text_ellipsis()
.child(description),
)
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
.child(
h_flex()
.mt_2()
.w_full()
.gap_0p5()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.font_semibold()
.child("Maintainers:"),
)
.child(self.render_maintainers(cx)),
),
)
.child(
h_flex()
.flex_none()
.gap_2()
.justify_end()
.child(
DropdownButton::new("issues")
.action(
BaseButton::new("issues-open")
.child(
h_flex()
.h_8()
.px_2()
.gap_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.hover(|this| {
this.bg(cx.theme().secondary_hover)
})
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(Icon::new(CustomIconName::GitIssueDone))
.child("Issues")
.child(
div()
.mx_1()
.h_5()
.w_px()
.bg(cx.theme().border.darken(0.1)),
)
.child(issue_count),
)
.on_click(cx.listener(|this, _event, window, cx| {
this.open_issue_detail(window, cx);
})),
)
.dropdown_menu(|menu, _, _| {
menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Plus))
.child("New issue")
})
}),
)
.child(
DropdownButton::new("prs")
.action(
BaseButton::new("prs-open")
.child(
h_flex()
.h_8()
.px_2()
.gap_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.hover(|this| {
this.bg(cx.theme().secondary_hover)
})
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(Icon::new(
CustomIconName::GitPullRequest,
))
.child("Pull Requests")
.child(
div()
.mx_1()
.h_5()
.w_px()
.bg(cx.theme().border.darken(0.1)),
)
.child(pr_count),
)
.on_click(cx.listener(|this, _event, window, cx| {
this.open_pull_request_detail(window, cx);
})),
)
.dropdown_menu(|menu, _, _| {
menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Plus))
.child("New Pull Request")
})
.menu_element(
Box::new(RepoAction::SendPatch),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::File))
.child("Send Patch")
},
)
}),
)
.child(
DropdownButton::new("share")
.action(
Button::new("link")
.icon(IconName::Copy)
.tooltip("Copy ID")
.secondary()
.on_click({
let naddr = share.naddr.clone();
move |_, _, cx| {
cx.write_to_clipboard(
ClipboardItem::new_string(naddr.clone()),
);
}
}),
)
.dropdown_menu(move |menu, _, _| share.menu(menu)),
)
.child(
Button::new("repo-menu-open")
.icon(IconName::EllipsisVertical)
.tooltip("Repository management")
.compact()
.secondary()
.loading(pushing)
.disabled(pushing)
.dropdown_menu(move |menu, _, cx| {
let backend = Backend::global(cx);
let current_user = backend.read(cx).current_user();
let owner = current_user == Some(announcement.owner);
let menu = menu.menu_element(
Box::new(RepoAction::About),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Info))
.child("About")
},
);
if owner {
menu.menu_element(Box::new(RepoAction::Push), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(CustomIconName::Init))
.child("Republish")
})
.separator()
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().danger)
.child(Icon::new(IconName::Delete))
.child("Delete")
})
} else {
menu
}
}),
)
.child({
let view = cx.entity();
let ngit_command = ngit_command.clone();
let nak_command = nak_command.clone();
let git_commands = git_commands.clone();
Popover::new("clone")
.anchor(Anchor::TopRight)
.trigger(
Button::new("clone")
.icon(CustomIconName::GitClone)
.tooltip("Clone")
.loading(cloning)
.disabled(cloning)
.primary(),
)
.content(move |_, _window, cx| {
let state = cx.entity();
let ngit_row = copy_row("copy-ngit", &ngit_command, cx);
let nak_row = copy_row("copy-nak", &nak_command, cx);
v_flex()
.w(px(440.))
.mt_1()
.p_3()
.gap_4()
.popover_style(cx)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Clone with ngit"),
)
.child(ngit_row),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Clone with nak"),
)
.child(nak_row),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Grasp Servers"),
)
.when(!git_commands.is_empty(), |this| {
this.children(
git_commands.iter().enumerate().map(
|(ix, cmd)| {
copy_row(
format!("copy-git-{ix}"),
cmd,
cx,
)
},
),
)
})
.when(git_commands.is_empty(), |this| {
this.child(
div()
.text_xs()
.child("No git clone urls."),
)
}),
)
.child(div().h_px().w_full().bg(cx.theme().border))
.child(
h_flex().gap_1().justify_end().child(
Button::new("download")
.icon(CustomIconName::GitClone)
.label("Download")
.primary()
.on_click(move |_event, window, cx| {
state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
view.update(cx, |this, cx| {
this.clone_to_folder(window, cx);
});
}),
),
)
})
}),
),
)
.child(self.render_header_tabs(cx))
.into_any_element()
}
/// Header for a local, not yet published, repository.
/// The directory name and path with an Init button instead of the NIP-34 actions.
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
let name = self.display_name(cx);
let path = self
.local_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default();
let avatar = PixelAvatar::new(path.clone());
v_flex()
.px_4()
.pb_4()
.w_full()
.gap_8()
.border_b_1()
.border_color(cx.theme().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()
.min_h_8()
.font_semibold()
.child(avatar.size_6())
.child(name),
)
.child(
div()
.min_w_0()
.text_sm()
.text_color(cx.theme().muted_foreground)
.line_clamp(2)
.line_height(relative(1.25))
.text_ellipsis()
.child(path),
),
)
.child(
Button::new("init")
.icon(CustomIconName::Init)
.label("Initialize on Nostr")
.primary()
.tooltip("Publish this repository to Nostr")
.on_click(cx.listener(|this, _event, window, cx| {
this.open_init_dialog(window, cx);
})),
),
)
.child(self.render_header_tabs(cx))
.into_any_element()
}
/// The tab row shared by both header variants.
/// Files and Commits tabs, the HEAD commit button and the branch/tag selectors.
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none();
h_flex()
.items_center()
.gap_2()
.child(
BaseButton::new("files-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitFile).small())
.child("Files"),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 0)
.when(self.active_tab == 0, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 0;
cx.notify();
})),
)
.child(
BaseButton::new("commits-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitCommit).small())
.child("Commits"),
)
.when_some(commits_count, |this, count| {
this.child(CountBadge::new(count))
})
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 1)
.when(self.active_tab == 1, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 1;
cx.notify();
})),
)
.child(
h_flex()
.flex_1()
.gap_2()
.justify_end()
.child(
Button::new("enc")
.ghost()
.when_some(self.head_commit.as_ref(), |this, commit| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(&commit.id)),
)
.child(
div()
.max_w(px(200.))
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_xs()
.child(SharedString::from(&commit.summary)),
)
})
.tooltip(
self.head_commit
.as_ref()
.map_or_else(SharedString::default, |commit| {
commit.summary.clone().into()
}),
)
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(commit) = &this.head_commit {
let id = commit.id.clone();
this.open_commit_diff(&id, window, cx);
}
})),
)
.child(
div().w(px(120.)).child(
Combobox::new(&self.branch_select)
.placeholder("Branch")
.appearance(false)
.menu_width(px(200.))
.disabled(worktree_empty)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
)
.child(
div().w(px(120.)).child(
Combobox::new(&self.tag_select)
.placeholder("Tag")
.appearance(false)
.menu_width(px(200.))
.disabled(worktree_empty)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
ref_selector_trigger(ctx, CustomIconName::Tag, cx)
}),
),
),
)
.into_any_element()
}
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(announcement) = self.announcement(cx) else {
return div().into_any_element();
};
let profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new();
let rest: Vec<_> = announcement
.maintainers
.iter()
.copied()
.filter(|key| key != &announcement.owner && seen.insert(*key))
.collect();
let owner = profile_store.read(cx).get(&announcement.owner);
let owner_name = owner.name();
let owner_picture = owner.picture();
h_flex()
.w_full()
.gap_3()
.child(
Button::new("maintainers").compact().ghost().child(
h_flex()
.gap_2()
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(owner_name.clone()).picture(owner_picture))
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
)
.when(!rest.is_empty(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!("+{}", rest.len()))),
)
}),
),
)
.into_any_element()
}
}
/// The `nostr://...` clone URL of an announcement, NIP-34.
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner;
let user = nip05
.map(str::to_owned)
.unwrap_or_else(|| owner.to_bech32().unwrap());
let mut url = format!("nostr://{user}");
if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) {
url.push('/');
url.push_str(hint);
}
url.push('/');
url.push_str(&announcement.id);
SharedString::from(url)
}
/// The forked-from row of the detail header.
///
/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo.
/// Plain text when it only carries a git URL.
fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> {
let upstream = announcement.upstream.as_ref()?;
let (label, clickable) = match &upstream.addr {
Some(addr) => {
// Prefer the upstream's display name when its announcement is known locally.
// Fall back to its repository id otherwise.
let name = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == *addr)
.map(|a| {
a.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
(SharedString::from(format!("Forked from {name}")), true)
}
None => (SharedString::from(upstream.display().as_str()), false),
};
let row = h_flex()
.gap_1()
.items_center()
.min_w_0()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::GitBranch).small())
.child(div().whitespace_nowrap().text_ellipsis().child(label));
Some(if clickable {
row.id("fork-upstream")
.cursor_pointer()
.hover(|this| this.text_color(cx.theme().foreground))
.on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx)))
.into_any_element()
} else {
row.into_any_element()
})
}
-789
View File
@@ -1,789 +0,0 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, Entity, SharedString, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::menu::PopupMenu;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::tag::Tag;
use gpui_component::tree::TreeItem;
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
use nostr::prelude::{Event, EventId, PublicKey};
use signed_core::Announcement;
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
use utils::{relative_time, relative_time_secs};
pub(crate) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
id: String,
/// File or directory name.
label: String,
children: Vec<TreeItemSeed>,
}
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label);
if expand_folders && !seed.children.is_empty() {
item = item.expanded(true);
}
item.children = seed
.children
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect();
item
}
seeds
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect()
}
/// Build nested tree items from a flat entry list sorted dirs-first.
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, so parents resolve in constant time while inserting.
let mut index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
for entry in entries {
let mut parent: Option<usize> = None;
let mut path = String::new();
for part in entry.components() {
let label = part.as_os_str().to_string_lossy().into_owned();
path = if path.is_empty() {
label.clone()
} else {
format!("{path}/{label}")
};
let ix = *index.entry(path.clone()).or_insert_with(|| {
let ix = nodes.len();
nodes.push((path.clone(), label.clone(), Vec::new()));
match parent {
Some(parent) => nodes[parent].2.push(ix),
None => roots.push(ix),
}
ix
});
parent = Some(ix);
}
}
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
let (id, label, children) = &nodes[ix];
TreeItemSeed {
id: id.clone(),
label: label.clone(),
children: children
.iter()
.map(|child| assemble(*child, nodes))
.collect(),
}
}
roots.iter().map(|root| assemble(*root, &nodes)).collect()
}
/// Sorted relative paths of a worktree snapshot.
///
/// Compared against the `worktree_paths` of a repository panel to skip
/// rebuilding the explorer when a refresh left the worktree unchanged.
pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
let mut paths: Vec<String> = entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
paths.sort();
paths
}
/// The markdown fence language for a file path, or `None` for plain text.
pub(crate) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
// Some common files are recognized by name rather than extension.
match name {
"Makefile" | "makefile" => return Some("make"),
"CMakeLists.txt" => return Some("cmake"),
_ => {}
}
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => "rust",
"toml" => "toml",
"json" | "jsonc" => "json",
"py" => "python",
"js" | "mjs" | "cjs" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" | "jsx" => "tsx",
"go" => "go",
"c" | "h" => "c",
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
"cs" => "csharp",
"java" => "java",
"kt" | "kts" | "ktm" => "kotlin",
"swift" => "swift",
"php" | "phtml" => "php",
"rb" => "ruby",
"sh" | "bash" | "zsh" => "bash",
"yml" | "yaml" => "yaml",
"css" | "scss" | "sass" => "css",
"html" | "htm" => "html",
"lua" => "lua",
"sql" => "sql",
"proto" | "protobuf" => "proto",
"cmake" => "cmake",
"zig" => "zig",
"ex" | "exs" => "elixir",
"graphql" | "gql" => "graphql",
"diff" | "patch" => "diff",
"svelte" => "svelte",
"astro" => "astro",
"scala" => "scala",
_ => return None,
})
}
/// Whether a file path has a markdown extension.
pub(crate) fn is_markdown_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"md" | "markdown" | "mdown" | "mkdn"
)
})
}
pub(crate) struct ShareTargets {
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
pub(crate) naddr: String,
/// Hex ID of the announcement event itself.
pub(crate) event_id: String,
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
pub(crate) coordinate: String,
/// `https://gitworkshop.dev/<naddr>`
pub(crate) gitworkshop: String,
/// `https://ditto.pub/<naddr>`
pub(crate) ditto: String,
}
impl ShareTargets {
pub(crate) fn from_announcement(announcement: &Announcement) -> Self {
let addr = announcement.addr();
let coordinate = addr.to_string();
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
.to_bech32()
.expect("a complete coordinate always encodes to naddr");
Self {
naddr: naddr.clone(),
event_id: announcement.event_id.to_bech32().unwrap(),
coordinate,
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
ditto: format!("https://ditto.pub/{naddr}"),
}
}
/// The share dropdown menu, one row per target.
///
/// Each shows a compact label, the copy button and row click copy the full value.
pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.))
.item(menu_copy_row(
"copy-gitworkshop",
"GitWorkshop",
truncate_naddr_link(&self.gitworkshop, 4),
self.gitworkshop.clone(),
))
.item(menu_copy_row(
"copy-ditto",
"Ditto",
truncate_naddr_link(&self.ditto, 4),
self.ditto.clone(),
))
.item(menu_copy_row(
"copy-event-id",
"Event ID",
middle_truncate(&self.event_id, 10, 10),
self.event_id.clone(),
))
.item(menu_copy_row(
"copy-coordinate",
"Coordinate",
middle_truncate(&self.coordinate, 10, 10),
self.coordinate.clone(),
))
}
}
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
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();
};
if url.len() - end <= tail + 3 {
return url.to_string();
}
format!("{}...{}", &url[..end], &url[url.len() - tail..])
}
/// Width of one line-number gutter in a diff row.
pub(crate) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list.
pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of a virtual diff list, a hunk header or a line of a hunk.
///
/// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)]
pub(crate) enum DiffRow {
Hunk {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
},
/// Line `line` of hunk `hunk` of the selected file's diff.
Line { hunk: usize, line: usize },
}
/// The rows of `file`'s diff, one header row per hunk then its lines.
pub(crate) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
old_start: hunk.old_start,
old_lines: hunk.old_lines,
new_start: hunk.new_start,
new_lines: hunk.new_lines,
});
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
hunk: hunk_ix,
line,
}));
}
rows
}
/// One row of the virtual diff list, a hunk header or a single line.
pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
old_start,
old_lines,
new_start,
new_lines,
} => div()
.px_2()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
}
}
/// One diff line, old and new line numbers in the gutters.
///
/// The content is tinted by kind, addition, deletion or context.
pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap, the virtual list assumes every row has the same height.
// Long lines are clipped instead of wrapped.
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.items_center()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
/// Find a tree item by id, searching into nested children.
pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
/// The root issue events of a repo store, for the shared detail sections.
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
&store.issues
}
/// The root pull request events of a repo store, for the shared detail sections.
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
&store.pull_requests
}
/// The trigger body of the branch/tag selectors.
///
/// The kind icon, the selection or placeholder, and the caret.
/// `Combobox` replaces its default trigger entirely,
/// the only way to show an icon inside it.
pub(crate) fn ref_selector_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName,
cx: &App,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_center()
.child(Icon::new(icon).small().flex_shrink_0())
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
.child(
ctx.selection()
.first()
.map(|(_, item)| item.clone())
.or_else(|| ctx.placeholder().cloned())
.unwrap_or_default(),
),
)
.child(Caret::new(ctx.size()).text_color(muted))
.into_any_element()
}
/// Section heading of a detail sidebar, shared by the issue and PR panels.
pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(text.to_string())
.into_any_element()
}
/// Right sidebar with participants and labels of a root event, issue or PR.
pub(crate) fn sidebar_section(
store: &Entity<RepoStore>,
id: EventId,
roots: fn(&RepoStore) -> &[Event],
top_gap: bool,
cx: &App,
) -> AnyElement {
let store = store.read(cx);
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
// The caller bails out when the root is missing.
return div().into_any_element();
};
let profile_store = ProfileStore::global(cx);
// Participants, the root author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![root.pubkey];
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex);
participants.dedup();
// Labels are NIP-34 `t` hashtag tags on the event.
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
v_flex()
.w(px(240.))
.h_full()
.flex_none()
.px_4()
.gap_4()
.border_l(px(1.))
.border_color(cx.theme().sidebar_border)
.child(
v_flex()
.when(top_gap, |this| this.mt_4())
.gap_2()
.child(sidebar_title("Participants", cx))
.children(participants.iter().map(|pubkey| {
let profile = profile_store.read(cx).get(pubkey);
let name = profile.name();
let picture = profile.picture();
h_flex()
.gap_1()
.items_center()
.child(UserAvatar::new(name.clone()).picture(picture))
.child(div().text_sm().truncate().text_ellipsis().child(name))
.into_any_element()
})),
)
.child(
v_flex()
.gap_2()
.child(sidebar_title("Labels", cx))
.map(|this| {
if labels.is_empty() {
this.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("None yet."),
)
} else {
this.child(h_flex().gap_1().children({
let mut items = vec![];
for label in labels.iter() {
items.push(
Tag::secondary()
.outline()
.xsmall()
.child(SharedString::from(label)),
);
}
items
}))
}
}),
)
.into_any_element()
}
/// The comments on a root event, issue or PR, one card per comment.
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
let store = store.read(cx);
let comments: Vec<&Event> = store.comments_of(&root).collect();
let title = SharedString::from(format!("Discussions {}", comments.len()));
v_flex()
.gap_4()
.child(div().text_xs().font_semibold().child(title))
.children(comments.iter().map(|comment| {
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
let content = SharedString::from(comment.content.as_str());
v_flex()
.gap_1()
.p_3()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(author),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child("commented"),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
/// The comment form posting to an issue or PR root event.
///
/// `roots` selects the root's list within the store, issues or pull requests.
pub(crate) fn comment_form(
store: &Entity<RepoStore>,
root: EventId,
roots: fn(&RepoStore) -> &[Event],
comment_input: &Entity<TextareaState>,
button_id: &'static str,
cx: &App,
) -> AnyElement {
let comment_input = comment_input.clone();
let store = store.clone();
v_flex()
.gap_2()
.child(
Textarea::new(&comment_input)
.h_24()
.text_color(cx.theme().muted_foreground)
.bg(cx.theme().muted),
)
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::Markdown).small())
.child("Markdown is supported"),
)
.child(
Button::new(button_id)
.primary()
.label("Comment")
.tooltip("Post comment")
.on_click(move |_event, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = roots(store.read(cx))
.iter()
.find(|event| event.id == root)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
)
.into_any_element()
}
/// Height of one commit row in a commit virtual list.
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
/// One commit row of a virtual list, shared by the commits tab and the
/// new-pull-request commit picker.
pub(crate) fn commit_row(
ix: usize,
commit: &FileCommit,
on_click: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> AnyElement {
h_flex()
.id(ix)
.px_4()
.h(px(COMMIT_ROW_HEIGHT))
.w_full()
.gap_3()
.items_center()
.border_b(px(1.))
.border_color(cx.theme().border)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.justify_center()
.child(
h_flex()
.gap_2()
.items_center()
.overflow_hidden()
.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
.child(
h_flex()
.gap_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.author.clone())
.child(relative_time_secs(commit.time)),
),
)
.on_click(move |_event, window, cx| on_click(window, cx))
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_nested_tree_from_flat_entries() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
PathBuf::from("docs/guide.md"),
];
let items = build_tree_items(&entries);
// Input order is preserved, dirs-first as produced by worktree_entries.
assert_eq!(items.len(), 3);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].id, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "lib.rs");
assert_eq!(items[0].children[0].id, "src/lib.rs");
assert_eq!(items[1].label, "README.md");
assert_eq!(items[1].id, "README.md");
assert_eq!(items[2].label, "docs");
assert_eq!(items[2].children[0].label, "guide.md");
assert_eq!(items[2].children[0].id, "docs/guide.md");
}
#[test]
fn tree_builder_handles_deep_nesting() {
let entries = vec![
PathBuf::from("a"),
PathBuf::from("a/b"),
PathBuf::from("a/b/c.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 1);
assert_eq!(items[0].children[0].id, "a/b");
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
}
#[test]
fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories' entries.
// The worktree list is dirs-first globally.
// The shared prefix must still resolve to one node.
let entries = vec![
PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"),
PathBuf::from("a/z.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "a");
assert_eq!(items[0].children.len(), 2);
assert_eq!(items[1].label, "b");
}
#[test]
fn tree_seeds_convert_to_tree_items() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/main.rs"),
PathBuf::from("README.md"),
];
let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "main.rs");
}
#[test]
fn code_language_maps_extensions_and_names() {
assert_eq!(code_language("src/main.rs"), Some("rust"));
assert_eq!(code_language("Cargo.toml"), Some("toml"));
assert_eq!(code_language("app.js"), Some("javascript"));
assert_eq!(code_language("index.tsx"), Some("tsx"));
assert_eq!(code_language("Makefile"), Some("make"));
assert_eq!(code_language("CMakeLists.txt"), Some("cmake"));
assert_eq!(code_language("data.csv"), None);
assert_eq!(code_language("LICENSE"), None);
assert_eq!(code_language("README.md"), None);
}
#[test]
fn naddr_link_keeps_url_and_tail() {
assert_eq!(
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
"https://gitworkshop.dev/naddr1...1234"
);
// Without the naddr1 prefix, unchanged.
assert_eq!(
truncate_naddr_link("https://example.com/x", 4),
"https://example.com/x"
);
}
}
+118 -145
View File
@@ -2,20 +2,130 @@ use std::path::PathBuf;
use std::rc::Rc;
use anyhow::Error;
use dock::{add_center_panel, panel_handle};
use dock::{DockArea, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{AnyElement, Context, Window, div, px, size};
use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size};
use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list};
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
use signed_git::CommitList;
use signed_state::RepoStore;
use signed_ui::placeholder;
use super::RepoDetailView;
use crate::views::commit_diff::CommitDiffView;
use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row};
use super::repo_display_name;
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row};
impl RepoDetailView {
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
pub(super) struct RepoHistoryView {
store: Entity<RepoStore>,
dock_area: WeakEntity<DockArea>,
worktree: Option<PathBuf>,
all_commits: Option<CommitList>,
loading_all_commits: bool,
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Bumped on reload, so an in-flight walk of the previous HEAD is discarded.
generation: u64,
tasks: Vec<Task<Result<(), Error>>>,
}
impl RepoHistoryView {
pub(super) fn new(store: Entity<RepoStore>, dock_area: WeakEntity<DockArea>) -> Self {
Self {
store,
dock_area,
worktree: None,
all_commits: None,
loading_all_commits: false,
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
generation: 0,
tasks: Vec::new(),
}
}
pub(super) fn set_worktree(&mut self, path: Option<PathBuf>) {
self.worktree = path;
}
/// Number of commits reachable from HEAD, for the Commits tab badge.
pub(super) fn commit_count(&self) -> Option<usize> {
self.all_commits.as_ref().map(|list| list.total)
}
/// Drop the current list and walk HEAD again.
pub(super) fn reload(&mut self, cx: &mut Context<Self>) {
self.generation += 1;
self.all_commits = None;
self.loading_all_commits = false;
self.load(cx);
}
fn load(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
self.loading_all_commits = true;
let generation = self.generation;
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
.await;
this.update(cx, |this, cx| {
if generation != this.generation {
return;
}
if let Ok(list) = result {
let count = list.commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.all_commits = Some(list);
}
this.loading_all_commits = false;
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
pub(super) fn open_commit_diff(
&mut self,
commit_id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
// Same display name as the repo detail panel's title.
let repo_name = repo_display_name(self.store.read(cx));
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
}
impl Render for RepoHistoryView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
v_flex()
@@ -33,9 +143,6 @@ impl RepoDetailView {
return placeholder("No commits found", cx);
}
// Copy only the values the element tree needs.
// The list is borrowed by the renderer below instead of cloned per frame.
// A full history can be tens of thousands of commits.
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
@@ -80,8 +187,6 @@ impl RepoDetailView {
.size_full(),
)
.when(shown < total, |this| {
// The history is capped.
// Tell the user the list is truncated.
this.child(
div()
.py_2()
@@ -103,135 +208,3 @@ impl RepoDetailView {
.into_any_element()
}
}
impl RepoDetailView {
/// Queue `path` for the per-file commit query.
/// Requests are batched into one history walk, see [`Self::load_commits`].
pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
/// Walk history once for every queued path on a background task.
/// Cache the latest commit touching each path in [`Self::commits`].
/// That feeds the file header in the content column.
/// Batching shares one walk across paths queued while the previous walk ran.
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let generation = self.ref_generation;
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(
async move { signed_git::worktree_last_commits(&worktree, &rels) },
)
.await;
this.update(cx, |this, cx| {
this.loading_commits = false;
if generation == this.ref_generation
&& let Ok(found) = result
{
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
// Paths queued while the walk was in flight start the next batch.
// A stale walk, branch switched mid-flight, must not strand them.
// This runs under the current generation regardless of the result.
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
Ok(())
});
task.detach();
}
/// Walk all commits reachable from HEAD on a background task.
/// For the Commits tab and its total-count badge.
/// [`CommitList`] caps the list, only the newest commits are materialized.
pub(super) fn load_all_commits(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
self.loading_all_commits = true;
let generation = self.ref_generation;
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
.await;
this.update(cx, |this, cx| {
// A stale walk, branch switched mid-flight, must not leave the flag set.
// Otherwise the Commits tab would spin forever.
if generation != this.ref_generation {
this.loading_all_commits = false;
return;
}
if let Ok(list) = result {
let count = list.commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.all_commits = Some(list);
}
this.loading_all_commits = false;
cx.notify();
})?;
Ok(())
});
task.detach();
}
/// Open a new panel showing the diff of `commit_id`.
pub(super) fn open_commit_diff(
&mut self,
commit_id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
// Same display name as the repo detail panel's title.
let repo_name = self.display_name(cx);
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
}
@@ -19,10 +19,8 @@ use crate::views::sidebar::grasp_servers::{
GraspServersState, grasp_servers_field, load_user_grasp_servers,
};
/// Shared state for the Init dialog, so async results can be rendered.
pub type InitRepoState = DialogProgress;
/// Open the Init dialog for the local repository at `local_path`.
pub fn open(
local_path: PathBuf,
view: WeakEntity<RepoDetailView>,
@@ -51,7 +49,6 @@ pub fn open(
.placeholder("Short description")
});
// Load the user's grasp servers.
load_user_grasp_servers(grasp_state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
@@ -146,9 +143,6 @@ pub fn open(
});
}
/// Run the init flow.
///
/// Closes the dialog and switches the repository into NIP-34 mode on success.
fn init_repository(
local_path: PathBuf,
inputs: (Entity<InputState>, Entity<TextareaState>),
-429
View File
@@ -1,429 +0,0 @@
use std::path::{Path, PathBuf};
use anyhow::Error;
use gix::Repository;
use gpui::prelude::*;
use gpui::{Context, Entity, PathPromptOptions, SharedString, Window};
use gpui_component::combobox::ComboboxState;
use gpui_component::searchable_list::SearchableVec;
use nostr::prelude::Url;
use signed_git::FileCommit;
use signed_state::GitStore;
use super::RepoDetailView;
use crate::views::repo::helpers::{
TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items,
};
/// Everything loaded from the local clone for the explorer.
struct RepoData {
tree: Vec<TreeItemSeed>,
/// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`].
entries: Vec<PathBuf>,
readme_path: Option<PathBuf>,
readme: Option<Vec<u8>>,
worktree: Option<PathBuf>,
branches: Vec<String>,
tags: Vec<String>,
current_branch: Option<String>,
head_commit: Option<FileCommit>,
}
impl RepoDetailView {
/// Load the repository and populate the file explorer.
///
/// A local, not yet published, repository opens straight from disk.
/// An announced repository's clone, if any, loads first without touching the network.
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
// Local repositories live on disk at their scan path.
// No clone step or network refresh applies here.
if let Some(local_path) = self.local_path.clone() {
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let data = cx
.background_spawn(async move {
let repo = gix::open(&local_path)?;
load_repo_data(&repo)
})
.await;
this.update_in(cx, |this, window, cx| {
match data {
Ok(data) => this.apply_repo_data(data, window, cx),
Err(error) => this.error = Some(error.to_string().into()),
}
this.loading = false;
cx.notify();
})?;
Ok(())
});
task.detach();
return;
}
let Some(initial) = self.initial.as_ref() else {
return;
};
let cache = GitStore::global(cx).cache().clone();
let addr = initial.addr();
let clone_urls: Vec<Url> = initial.clone.clone();
// Captured before the loads start.
// A branch/tag switch bumps the generation, discarding the refresh below.
let refresh_generation = self.ref_generation;
let disk = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
match cache.open(&addr)? {
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
None => Ok(None),
}
})
};
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let disk = disk.await;
let had_clone = matches!(&disk, Ok(Some(_)));
// No local clone yet, so clone from the network then load.
let data = match disk {
Ok(Some(data)) => Ok(data),
Ok(None) => {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
load_repo_data(&repo)
})
.await
}
Err(error) => Err(error),
};
this.update_in(cx, |this, window, cx| {
match data {
Ok(data) => this.apply_repo_data(data, window, cx),
Err(error) => this.error = Some(error.to_string().into()),
}
this.loading = false;
cx.notify();
})?;
// Refresh the clone from the network in the background.
// When it completes, update the refs and commit list.
// Loads started before a branch/tag switch are discarded via the generation.
if !had_clone {
return Ok(());
}
let refresh = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
let Some(repo) = cache.open(&addr)? else {
return Ok::<_, Error>(None);
};
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
// The state is already shown.
signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf);
// A fetch never moves a mirror's local branches.
// A push landing on the grasp servers would never show up.
// That covers own repo pushes from a checkout and updates fetched here.
// Fast-forward branches from the remote, like `git pull --ff-only`.
// Only the checked-out branch's worktree can change on disk.
let moved = match &worktree {
Some(worktree) => {
signed_git::fast_forward_branches(worktree).unwrap_or(false)
}
None => false,
};
let (branches, tags) = match &worktree {
Some(_) => (
signed_git::repo_branches(&repo).unwrap_or_default(),
signed_git::repo_tags(&repo).unwrap_or_default(),
),
None => (Vec::new(), Vec::new()),
};
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
})
}
.await;
this.update_in(cx, |this, window, cx| {
if refresh_generation != this.ref_generation {
return;
}
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
let branches_changed = Self::sync_ref_selector(
&this.branch_select,
&mut this.ref_branches,
branches,
current_branch.map(Into::into),
window,
cx,
);
let tags_changed = Self::sync_ref_selector(
&this.tag_select,
&mut this.ref_tags,
tags,
None,
window,
cx,
);
let new_head_commit = head_commit.as_ref().map(|c| &c.id);
let current_head_commit = this.head_commit.as_ref().map(|c| &c.id);
let head_changed = new_head_commit != current_head_commit;
this.head_commit = head_commit;
log::debug!(
"repo detail refresh reconcile: branches_changed={branches_changed} tags_changed={tags_changed} head_changed={head_changed} moved={moved}"
);
// Only a moved HEAD invalidates the commit list.
// Leaving an in-flight walk alone when HEAD did not move
// keeps a refresh that learned nothing new from flashing
// the commits tab.
if head_changed {
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
}
// A fast-forward may touch a branch that is not checked out.
// `catch_up_worktree` no-ops when the tree is unchanged and
// re-renders only when it actually rebuilt something.
if moved {
this.catch_up_worktree(cx);
}
if branches_changed || tags_changed || head_changed {
cx.notify();
}
}
})?;
Ok(())
});
task.detach();
}
/// Apply the loaded repository data.
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
log::debug!("repo detail: apply_repo_data");
let RepoData {
tree,
entries,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
} = data;
let Some(worktree) = worktree else {
self.error = Some("Repository has no worktree".into());
return;
};
self.worktree = Some(worktree);
self.head_commit = head_commit;
self.worktree_paths = sorted_worktree_paths(&entries);
self.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
// Populate the branch/tag selectors with the local refs.
// Select the branch HEAD points to.
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
Self::sync_ref_selector(
&self.branch_select,
&mut self.ref_branches,
branches,
current_branch.map(Into::into),
window,
cx,
);
Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx);
self.load_all_commits(cx);
if let Some((path, bytes)) = readme_path.zip(readme) {
self.readme_name = Some(path.to_string_lossy().into());
self.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
self.set_markdown(None, &text, cx);
}
}
}
/// Point a ref selector at `items`, selecting `selected` when given.
///
/// Updates the items and selection only when they differ from `cached` and
/// the current selection. `set_items`/`set_selected_values` notify the
/// combobox, which re-renders the header, so skipping the no-op keeps a
/// background refresh that learned nothing new from flashing the selectors.
/// Returns whether anything was set.
fn sync_ref_selector(
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let items_changed = *cached != items;
let selection_changed = selected
.as_ref()
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
if !items_changed && !selection_changed {
return false;
}
select.update(cx, |state, cx| {
if items_changed {
state.set_items(SearchableVec::from(items.clone()), window, cx);
}
if let Some(value) = selected
&& (items_changed || selection_changed)
{
state.set_selected_values(std::slice::from_ref(&value), window, cx);
}
});
*cached = items;
true
}
/// Clone the repository into a user-chosen folder outside the cache.
pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let name = {
let Some(announcement) = self.announcement(cx) else {
return;
};
let addr = announcement.addr();
// Directory name, the display name falling back to the repo id.
// Both are sanitized to a safe single path component.
let name = announcement
.name
.as_ref()
.map(|name| name.to_string())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| addr.identifier.clone());
let name = signed_git::sanitize_path_component(&name);
if name.is_empty() {
"repository".to_owned()
} else {
name
}
};
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Clone".into()),
});
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
// `Ok(Ok(Some(paths)))` means the user picked a folder.
// A cancel or picker failure resolves to anything else.
let picked = match prompt.await {
Ok(Ok(Some(mut paths))) => paths.pop(),
_ => None,
};
let Some(folder) = picked else {
return Ok(());
};
let destination = folder.join(&name);
let destination_for_open = destination.clone();
// The store owns the clone, its busy flag and error reporting.
let clone = this.update_in(cx, |_this, _window, cx| {
store.update(cx, |store, cx| store.clone_to_folder(destination, cx))
})?;
// Reveal the new clone in the system file manager on success.
// Failures already surfaced in the store's error banner.
if let Ok(()) = clone.await {
this.update_in(cx, |_this, _window, cx| {
cx.open_with_system(&destination_for_open);
})?;
}
Ok(())
});
task.detach();
}
}
/// Read the worktree state of `repo`, no network.
///
/// Entries, README, refs and HEAD commit.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
let readme_path = signed_git::find_readme(repo)?;
let readme = match &readme_path {
Some(path) => signed_git::worktree_read(repo, path)?,
None => None,
};
let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI.
// A broken ref must not prevent the explorer from loading.
// Failures degrade to empty selectors.
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
signed_git::repo_tags(repo).unwrap_or_default(),
signed_git::current_branch(repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
Ok(RepoData {
tree,
entries,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
})
}
File diff suppressed because it is too large Load Diff
+93 -263
View File
@@ -1,285 +1,115 @@
use std::collections::HashSet;
use anyhow::Error;
use gpui::prelude::*;
use gpui::{Context, Entity, SharedString, Window};
use gpui::{App, Entity, SharedString, Window};
use gpui_component::combobox::ComboboxState;
use gpui_component::searchable_list::SearchableVec;
use super::{RefKind, RepoDetailView};
use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items};
pub(super) struct RefSwitcher {
pub(super) branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
pub(super) tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
ref_branches: Vec<SharedString>,
ref_tags: Vec<SharedString>,
pub(super) switching_ref: bool,
}
impl RepoDetailView {
/// Check out `name`, a branch or tag picked in the header.
/// Refresh the explorer once the switch completes.
pub(super) fn switch_ref(
&mut self,
kind: RefKind,
name: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.switching_ref {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
// Branches and tags are mutually exclusive states of HEAD.
// Selecting one clears the other selector.
// Remember the previous selections to restore them if the checkout fails.
let previous_branch = self.branch_select.read(cx).selected_value();
let previous_tag = self.tag_select.read(cx).selected_value();
match kind {
RefKind::Branch => {
self.tag_select
.update(cx, |state, cx| state.clear_selection(cx));
}
RefKind::Tag => {
self.branch_select
.update(cx, |state, cx| state.clear_selection(cx));
}
}
self.switching_ref = true;
// In-flight loads of the previous branch are discarded when they complete.
self.ref_generation += 1;
cx.notify();
let checkout_name = name.clone();
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move {
match kind {
RefKind::Branch => {
signed_git::worktree_checkout_branch(&worktree, &checkout_name)
}
RefKind::Tag => {
signed_git::worktree_checkout_tag(&worktree, &checkout_name)
}
}
})
.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(()) => this.reload_worktree(cx),
Err(error) => {
this.error = Some(format!("Failed to check out {name}: {error}").into());
this.switching_ref = false;
this.restore_selection(&this.branch_select, &previous_branch, window, cx);
this.restore_selection(&this.tag_select, &previous_tag, window, cx);
}
}
cx.notify();
})?;
Ok(())
impl RefSwitcher {
pub(super) fn new(window: &mut Window, cx: &mut App) -> Self {
let branch_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let tag_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
task.detach();
Self {
branch_select,
tag_select,
ref_branches: Vec::new(),
ref_tags: Vec::new(),
switching_ref: false,
}
}
/// Restore a selector to `previous`, or clear it after a failed switch.
fn restore_selection(
pub(super) fn set_branches(
&mut self,
branches: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
sync_selector(
&self.branch_select,
&mut self.ref_branches,
branches,
selected,
window,
cx,
)
}
pub(super) fn set_tags(
&mut self,
tags: Vec<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
}
pub(super) fn restore_selection(
&self,
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
previous: &Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
cx: &mut App,
) {
select.update(cx, |state, cx| match previous {
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
None => state.clear_selection(cx),
});
}
/// Refresh the file explorer, preview pane and commit list after a successful switch.
/// The selectors were already updated by [`Self::switch_ref`].
/// [`Self::switching_ref`] stays set until this reload finishes.
/// A second switch cannot interleave.
fn reload_worktree(&mut self, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else {
return;
};
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move {
let snapshot = signed_git::worktree_snapshot(&worktree)?;
// Build the tree off the main thread, like [`Self::load_repo`].
let tree = build_tree_items(&snapshot.entries);
let paths = sorted_worktree_paths(&snapshot.entries);
Ok::<_, Error>((snapshot, tree, paths))
})
.await;
this.update(cx, |this, cx| {
this.switching_ref = false;
match result {
Ok((snapshot, tree, paths)) => {
this.head_commit = snapshot.head_commit;
this.worktree_paths = paths;
// Rebuild the tree from scratch.
// Entries of the previous branch are gone.
// The expansion state goes with them.
this.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
// Drop cached previews and commits of the old branch.
this.selected_file = None;
this.files.clear();
this.file_order.clear();
this.preview_bytes = 0;
this.loading_files.clear();
this.commits.clear();
this.pending_commits.clear();
this.loading_commits = false;
this.md = None;
this.code = None;
this.readme_name = None;
this.all_commits = None;
this.loading_all_commits = false;
if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) {
this.readme_name = Some(path.to_string_lossy().into());
this.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
this.set_markdown(None, &text, cx);
}
}
this.load_all_commits(cx);
}
Err(error) => {
this.error = Some(error.to_string().into());
this.head_commit = None;
this.worktree_paths.clear();
// The tree may show files that no longer exist.
this.tree_state.update(cx, |state, cx| {
state.set_items(Vec::new(), cx);
});
}
}
cx.notify();
})?;
Ok(())
});
task.detach();
}
fn sync_selector(
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
let items_changed = *cached != items;
let selection_changed = selected
.as_ref()
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
if !items_changed && !selection_changed {
return false;
}
/// Refresh the file explorer, previews and commit list after the mirror
/// caught up with the remote.
///
/// The checked-out branch fast-forwarded in place, so unlike
/// [`Self::reload_worktree`] this keeps the panel's selection and previews:
/// it rebuilds the tree, drops previews of files the refresh removed and
/// re-renders the README when it is on screen.
pub(super) fn catch_up_worktree(&mut self, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else {
return;
};
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move {
let snapshot = signed_git::worktree_snapshot(&worktree)?;
let tree = build_tree_items(&snapshot.entries);
let paths = sorted_worktree_paths(&snapshot.entries);
Ok::<_, Error>((snapshot, tree, paths))
})
.await;
this.update(cx, |this, cx| {
match result {
Ok((snapshot, tree, paths)) => {
let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id)
!= this.head_commit.as_ref().map(|c| &c.id);
// A fast-forward of a branch other than the checked-out
// one leaves the worktree untouched. Rebuilding the tree
// and re-parsing the README would flash the panel for
// nothing, so it is a no-op.
if !head_changed && paths == this.worktree_paths {
log::debug!("repo detail catch_up_worktree: no-op");
return;
}
log::debug!(
"repo detail catch_up_worktree: head_changed={head_changed} entries={}",
paths.len()
);
this.head_commit = snapshot.head_commit;
this.worktree_paths = paths;
this.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
// Drop previews of files the refresh removed from the worktree,
// everything else stays put.
let present: HashSet<String> = snapshot
.entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
let mut previewed: Vec<String> = Vec::new();
previewed.extend(this.files.keys().cloned());
previewed.extend(this.selected_file.clone().map(|p| p.to_string()));
if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) {
previewed.push(path.to_string());
}
if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) {
previewed.push(path.to_string());
}
previewed.sort();
previewed.dedup();
for path in previewed {
if !present.contains(&path) {
this.drop_preview_of(&path);
}
}
// Re-render the README when it is on screen, i.e. when no file preview is open.
if this.selected_file.is_none() {
match snapshot.readme_path.zip(snapshot.readme) {
Some((path, bytes)) => {
this.readme_name = Some(path.to_string_lossy().into());
if let Ok(text) = String::from_utf8(bytes) {
this.set_markdown(None, &text, cx);
}
}
None => {
this.md = None;
this.readme_name = None;
}
}
}
if head_changed {
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
}
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
task.detach();
}
select.update(cx, |state, cx| {
if items_changed {
state.set_items(SearchableVec::from(items.clone()), window, cx);
}
if let Some(value) = selected
&& (items_changed || selection_changed)
{
state.set_selected_values(std::slice::from_ref(&value), window, cx);
}
});
*cached = items;
true
}
-102
View File
@@ -1,102 +0,0 @@
use gpui::prelude::*;
use gpui::{Context, Entity};
use signed_core::Announcement;
use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore};
use super::RepoDetailView;
impl RepoDetailView {
/// Switch the repository into its NIP-34 mode after a successful init.
/// Creates the nostr store for the announced repository.
/// Drops the local scan identity.
/// The worktree is unchanged, so the explorer keeps its loaded content.
pub(crate) fn apply_announcement(
&mut self,
announcement: Announcement,
cx: &mut Context<Self>,
) {
// The repository is no longer a bare local repo.
// Drop it from the scan results so it leaves the sidebar's local section.
if let Some(path) = self.local_path.take() {
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
}
let store =
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
// Re-render on store refreshes, issues, PRs and statuses.
// Keep the ready-to-contribute statuses of this repository requested.
self.attach_store(&store, cx);
self.store = Some(store);
self.initial = Some(announcement);
cx.notify();
}
/// Observe the repository's store, re-render on refreshes.
/// Request the ready-to-contribute statuses for it.
pub(super) fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
self._subscriptions
.push(cx.observe(store, |this, _store, cx| {
log::debug!("repo detail: store notify");
this.refresh_ready_statuses(cx);
cx.notify();
}));
self.refresh_ready_statuses(cx);
}
/// Request the statuses of this repository again when the announced HEAD changes.
/// The HEAD is the base the checkouts are compared against.
/// Owned repositories are watched for unpushed commits.
/// Other repositories for ready-to-contribute checkouts.
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(entity) = self.store.clone() else {
return;
};
let head = entity.read(cx).head.clone();
if self.ready_requested && self.ready_head == head {
return;
}
self.ready_requested = true;
self.ready_head = head.clone();
let addr = entity.read(cx).addr().clone();
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let owned = backend
.read(cx)
.current_user()
.is_some_and(|user| entity.read(cx).is_author(&user));
checkout.update(cx, |store, cx| {
// The ready statuses keep the fast poll running while the panel is open.
// The sidebar's push watch alone polls slower.
store.request_statuses(&addr, head, cx);
if owned {
store.request_push_statuses(&addr, cx);
}
});
}
/// The ready-to-push statuses of this repository in the global checkouts
/// store changed since they last drove a render.
///
/// Updates the cached slices. `None` store (a local, not yet published,
/// repository) has no statuses.
pub(super) fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
let Some(entity) = self.store.clone() else {
return false;
};
let addr = entity.read(cx).addr().clone();
let checkouts = CheckoutsStore::global(cx).read(cx);
let ready_statuses = checkouts.ready_statuses_of(&addr);
let push_statuses = checkouts.push_statuses_of(&addr);
let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses;
self.ready_statuses = ready_statuses;
self.push_statuses = push_statuses;
changed
}
}
+7 -15
View File
@@ -24,18 +24,13 @@ use super::open_repo_panel;
const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
/// How many of the newest repositories the `Recent` sort shows.
const RECENT_COUNT: usize = 10;
/// Sort of the explore list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum RepoFilter {
/// Every repository in the store's default order, newest first.
All,
#[default]
/// Repositories ranked by total issues + pull requests + commits.
Popular,
/// The [`RECENT_COUNT`] newest repositories.
Recent,
}
@@ -100,23 +95,18 @@ impl RepoFilter {
}
}
/// Browse all announced repositories.
pub struct RepoListView {
store: Entity<RepoListStore>,
dock_area: WeakEntity<DockArea>,
focus_handle: FocusHandle,
scroll_handle: VirtualListScrollHandle,
/// Sort selected in the header filter buttons.
filter: RepoFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
repo_len: usize,
/// Indices matching [`Self::filter`] into the store's `announcements`.
visible: Vec<usize>,
/// Search box filtering repositories by name.
search: Entity<InputState>,
/// Rebuilds the visible slice as the search text changes.
_search_subscription: Subscription,
_subscription: Subscription,
}
@@ -129,7 +119,6 @@ impl RepoListView {
) -> Self {
let store = RepoListStore::global(cx);
// Live search over repository names
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
if matches!(event, InputEvent::Change) {
@@ -162,9 +151,6 @@ impl RepoListView {
}
}
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
///
/// Uses the store contents, [`Self::filter`] and the search query.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
let query = self.search.read(cx).value();
@@ -189,7 +175,13 @@ impl RepoListView {
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(&self.dock_area, announcement, window, cx);
open_repo_panel(
&self.dock_area,
&announcement.addr(),
Some(announcement),
window,
cx,
);
}
fn render_card(
-8
View File
@@ -13,19 +13,12 @@ use signed_state::RepoStore;
pub struct SendPatchView {
focus_handle: FocusHandle,
/// Dock area the panel lives in.
dock_area: WeakEntity<DockArea>,
/// Store of the target repository.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Title input, required.
subject: Entity<InputState>,
/// Description input, optional.
description: Entity<TextareaState>,
/// The pasted `git format-patch` output, required.
patch: Entity<TextareaState>,
/// A submit is in flight.
submitting: bool,
/// Error of the last submit attempt, it keeps the panel open.
error: Option<SharedString>,
@@ -61,7 +54,6 @@ impl SendPatchView {
}
}
/// Publish the pull request from the pasted patch.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting {
return;
@@ -17,10 +17,9 @@ use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the Create Repository dialog, so async results can be rendered.
/// Progress of the create-repository flow, so async results can be rendered.
pub type CreateRepoState = DialogProgress;
/// Open the Create Repository dialog.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let settings = SettingsStore::global(cx);
let default_folder = settings
@@ -151,7 +150,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
});
}
/// Pick the repository's storage folder with the platform's native folder picker.
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle();
let folder_input = folder_input.clone();
@@ -186,8 +184,6 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach();
}
/// Run the create-repository flow.
///
/// Opens the new working copy and the repository panel on success.
#[allow(clippy::too_many_arguments)]
fn create_repository(
@@ -250,12 +246,17 @@ fn create_repository(
.detach();
}
/// Open the newly created repository in the dock's center.
fn open_repo(
dock_area: WeakEntity<DockArea>,
announcement: Announcement,
window: &mut Window,
cx: &mut App,
) {
open_repo_panel(&dock_area, &announcement, window, cx);
open_repo_panel(
&dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
}
@@ -11,17 +11,16 @@ use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
#[derive(Default)]
pub struct GraspServersState {
/// The user's grasp list of kind `10317` is being loaded.
/// Set while the user's kind `10317` grasp list loads.
pub loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>,
/// Whether the grasp server section is shown. Defaults to shown.
pub servers_enabled: bool,
/// Error of the last grasp-server edit, an invalid relay URL for example.
/// Error from the last grasp-server edit, such as an invalid relay URL.
pub error: Option<SharedString>,
}
impl GraspServersState {
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
/// Defaults used until the user's grasp list loads and replaces them.
///
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
pub fn new_default(settings: &GraspServersSettings) -> Self {
@@ -137,7 +136,6 @@ pub fn grasp_servers_field(
}))
}
/// One grasp server row, the host in a tag plus a remove button.
fn render_server_row(
ix: usize,
relay: &RelayUrl,
@@ -176,7 +174,7 @@ fn render_server_row(
)
}
/// The bare host of a grasp server, defaults are entered without a scheme.
/// Shows only the host, since grasp servers are entered without a scheme.
fn display_server(relay: &RelayUrl) -> SharedString {
relay
.domain()
@@ -184,7 +182,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
/// Parse the relay input, accepting a bare host, and append it to the list.
/// Accepts a bare host as well as a full URL.
fn add_relay(
state: &Entity<GraspServersState>,
input: &Entity<InputState>,
@@ -220,9 +218,9 @@ fn add_relay(
}
}
/// Load the user's grasp list of kind `10317` from the local database.
/// Loads the user's kind `10317` grasp list from the local database.
///
/// It replaces the defaults when it lists any servers.
/// Replaces the defaults when the list is non-empty.
pub fn load_user_grasp_servers(
state: Entity<GraspServersState>,
window: &mut Window,
@@ -1,7 +1,6 @@
use gpui::{App, Window, px};
use gpui_component::WindowExt;
/// Open the Import Identity dialog.
pub fn open(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.))
+15 -27
View File
@@ -39,15 +39,13 @@ pub struct SidebarPanel {
dock_area: WeakEntity<DockArea>,
inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>,
/// Artwork for the sign-in screen.
banner: SharedString,
/// The signed-in user's announced repositories, newest first.
announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<PathBuf>>,
/// A local scan is currently running.
scanning: bool,
/// Unpushed local commits per announced repository, the row badge counts.
/// Unpushed commit counts per announced repository, shown as row badges.
unpushed: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>,
}
@@ -80,21 +78,19 @@ impl SidebarPanel {
}
}));
// The merged list re-derives when announcements or the local scan change.
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
if this.refresh(cx) {
cx.notify();
}
}));
// The local scan re-derives when announcements or the local scan change.
subscriptions.push(cx.observe(&local, |this, _local, cx| {
if this.refresh(cx) {
cx.notify();
}
}));
// Push statuses are recomputed in the background; only the badge counts change.
// Push statuses are recomputed in the background, so only the badge counts change.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
if this.refresh_unpushed(cx) {
cx.notify();
@@ -125,8 +121,7 @@ impl SidebarPanel {
.map(|user| repo_list.read(cx).announcements_of(user))
.unwrap_or_default();
// A scanned repository is dropped from the local list
// once the user announces it, so it is not listed twice.
// Drop a scanned repository once the user announces it, so it is not listed twice.
let local = LocalReposStore::global(cx);
let scanning = local.read(cx).scanning;
@@ -162,7 +157,6 @@ impl SidebarPanel {
announcements_changed || local_changed || scanning_changed
}
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
let checkouts = CheckoutsStore::global(cx);
let mut unpushed = HashMap::with_capacity(self.announcements.len());
@@ -183,7 +177,6 @@ impl SidebarPanel {
true
}
/// Keep the `ready to push` statuses of the announced repositories current.
fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
@@ -193,7 +186,6 @@ impl SidebarPanel {
});
}
/// Open the inbox home panel in the dock area's center.
pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() {
return;
@@ -209,7 +201,6 @@ impl SidebarPanel {
.ok();
}
/// Open the Explore repository list panel in the dock area's center.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self
.explore
@@ -230,7 +221,6 @@ impl SidebarPanel {
.ok();
}
/// Show the Onboarding dialog.
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name"));
let pass_input = cx.new(|cx| {
@@ -248,23 +238,25 @@ impl SidebarPanel {
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
}
/// Show the Create Repository dialog.
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
create_repo_dialog::open(self.dock_area.clone(), window, cx);
}
/// Open a repository's detail view in the dock's center.
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
open_repo_panel(
&self.dock_area,
&announcement.addr(),
Some(announcement),
window,
&mut *cx,
);
}
/// Open a local repository's detail view in the dock's center.
///
/// The detail view offers to publish it to NIP-34.
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let detail =
@@ -328,7 +320,7 @@ impl SidebarPanel {
),
)
.map(|this| {
// Merged list, the user's NIP-34 repositories and local repositories discovered.
// The merged list: NIP-34 repositories first, then discovered local repositories.
let total = announcements.len() + local_repos.len();
if total == 0 {
@@ -368,7 +360,7 @@ impl SidebarPanel {
})
}
/// One row of the merged sidebar list, a NIP-34 or a local repository.
/// Renders row `ix` of the merged list: an announced repository or a local one.
fn render_repo_at(
&self,
announcements: &[Announcement],
@@ -397,7 +389,6 @@ impl SidebarPanel {
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let announcement = announcement.clone();
// Badge with the unpushed commit count of the repository's local checkouts.
let unpushed = self
.unpushed
.get(&announcement.addr())
@@ -427,9 +418,7 @@ impl SidebarPanel {
)
}
/// One local repository row.
///
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
/// A local repository that is not yet set up for NIP-34, marked with a warning.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path
.file_name()
@@ -449,12 +438,11 @@ impl SidebarPanel {
}))
}
/// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx);
}
/// Render the user avatar and name in the sidebar, inside the titlebar drag area.
/// The user avatar and name, wired into the titlebar drag area.
fn render_user(
&self,
profile: &Profile,
@@ -484,7 +472,7 @@ impl SidebarPanel {
)
}
/// Sign-in placeholder shown while logged out.
/// Shown while no identity is signed in.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex()
.size_full()
@@ -9,10 +9,9 @@ use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the Onboarding dialog, so async results can be rendered.
/// Progress of the onboarding flow, so async results can be rendered.
pub type OnboardingState = DialogProgress;
/// Open the Onboarding dialog for creating a new identity.
pub fn open(
name_input: Entity<InputState>,
pass_input: Entity<InputState>,
@@ -10,16 +10,14 @@ use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
/// Shared state for the passphrase dialog, so async results can be rendered.
/// State of the passphrase dialog, so async results can be rendered.
#[derive(Default)]
pub struct PassphraseState {
/// Progress of the unlock flow.
pub progress: DialogProgress,
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
_enter_subscription: Option<Subscription>,
}
/// Open the dialog asking for the passphrase that protects the stored identity.
pub fn open(window: &mut Window, cx: &mut App) {
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -30,7 +28,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
let handle = window.window_handle();
let state = cx.new(|_| PassphraseState::default());
// Enter in the passphrase field submits, same as the Unlock button.
// Enter in the passphrase field submits, like the Unlock button.
let enter_pass_input = pass_input.clone();
let enter_state = state.clone();
let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| {
@@ -95,7 +93,6 @@ pub fn open(window: &mut Window, cx: &mut App) {
});
}
/// Submit the passphrase to the backend.
fn unlock(
pass_input: &Entity<InputState>,
state: &Entity<PassphraseState>,
@@ -22,7 +22,7 @@ use nostr::prelude::RelayUrl;
use settings::{AppearanceMode, Settings, SettingsStore};
use signed_ui::{SelectOption, setting_block, setting_row};
/// The index of `value` in `options`, for seeding a [`SelectState`].
/// Looks up the option index used to seed a [`SelectState`].
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
options
.iter()
@@ -30,7 +30,7 @@ fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
.map(|row| IndexPath::default().row(row))
}
/// The light and dark themes registered in the theme registry.
/// Registered themes split into light and dark options, light first.
fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
let registry = ThemeRegistry::global(cx);
let mut light = Vec::new();
@@ -48,7 +48,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
(light, dark)
}
/// Stateful controls of the settings dialog, created once when it opens.
/// Created once when the dialog opens, so control state survives re-renders.
struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>,
@@ -58,9 +58,9 @@ struct SettingsControls {
radius: Entity<InputState>,
radius_lg: Entity<InputState>,
grasp_server_input: Entity<InputState>,
/// The effective default create-repository folder, shown in the disabled input.
/// The effective create-repository folder, shown in a disabled input.
default_folder: Entity<InputState>,
/// Keeps the control subscriptions alive for the dialog's lifetime.
/// Keeps the control subscriptions alive while the dialog is open.
_subscriptions: Vec<Subscription>,
}
@@ -264,7 +264,6 @@ impl SettingsControls {
}
}
/// Open the Settings dialog.
pub fn open(window: &mut Window, cx: &mut App) {
let controls = Rc::new(SettingsControls::new(window, cx));
@@ -278,8 +277,6 @@ pub fn open(window: &mut Window, cx: &mut App) {
});
}
/// The settings content, one section per related setting.
/// Sections are divided by horizontal separator lines.
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
let store = SettingsStore::global(cx);
let settings = store.read(cx).settings().clone();
@@ -297,7 +294,6 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
.child(repositories_section(&settings, controls, cx))
}
/// How the app picks its appearance.
fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex().w_full().gap_3().child(setting_row(
cx,
@@ -307,7 +303,6 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
))
}
/// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex()
.gap_3()
@@ -378,7 +373,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
))
}
/// Default grasp servers offered until the user publishes a kind `10317` grasp list.
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
fn grasp_servers_section(
settings: &Settings,
controls: &SettingsControls,
@@ -394,7 +389,6 @@ fn grasp_servers_section(
))
}
/// The editable list of default grasp servers plus an add-relay input.
/// Styled like the grasp-server section of the publish dialogs.
fn grasp_server_editor(
servers: &[String],
@@ -459,7 +453,7 @@ fn grasp_server_editor(
)
}
/// The bare host of a grasp server, defaults are entered without a scheme.
/// 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)
@@ -469,7 +463,6 @@ fn display_server(server: &str) -> SharedString {
.unwrap_or_else(|| SharedString::from(server.to_owned()))
}
/// Local repository scanning and the create-repository dialog default folder.
fn repositories_section(
settings: &Settings,
controls: &SettingsControls,
@@ -494,7 +487,6 @@ fn repositories_section(
))
}
/// The editable list of scan directories plus an add-directory button.
/// Styled like the grasp-server list.
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
v_flex()
@@ -547,7 +539,6 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
)
}
/// The default-folder selector, a disabled input plus a picker button.
/// Matches the create-repository dialog.
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
let default_folder = controls.default_folder.clone();
@@ -571,8 +562,7 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
)
}
/// Parse the server input and append it to the default grasp servers.
/// A bare host is accepted.
/// Accepts a bare host as well as a full URL.
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
@@ -604,7 +594,6 @@ fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
/// Prompt for directories to add to the local-repository scan.
fn add_scan_path(cx: &mut App) {
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
@@ -641,8 +630,7 @@ fn add_scan_path(cx: &mut App) {
.detach();
}
/// Prompt for the Create Repository dialog's default folder.
/// Remember it in the settings and show it in the disabled input.
/// Persists the choice and reflects it in the disabled input.
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle();
let default_folder = default_folder.clone();
@@ -676,9 +664,7 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
.detach();
}
/// Wire a number input to the settings.
/// Step actions clamp and persist the value.
/// Typed changes parse, clamp and persist.
/// Step actions clamp and persist the value; typed changes parse, clamp and persist.
fn wire_number_input(
state: &Entity<InputState>,
subscriptions: &mut Vec<Subscription>,
@@ -751,7 +737,6 @@ fn wire_number_input(
}));
}
/// Apply the persisted appearance to the live theme.
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
match appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
@@ -760,7 +745,6 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
}
}
/// Re-apply the persisted theme configuration to the live theme.
fn apply_theme(cx: &mut App) {
let store = SettingsStore::global(cx);
let settings = store.read(cx).settings().theme.clone();
+153
View File
@@ -0,0 +1,153 @@
use std::collections::HashMap;
use std::path::PathBuf;
use gpui_component::tree::TreeItem;
pub(crate) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
pub(crate) id: String,
pub(crate) label: String,
pub(crate) children: Vec<TreeItemSeed>,
}
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label);
if expand_folders && !seed.children.is_empty() {
item = item.expanded(true);
}
item.children = seed
.children
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect();
item
}
seeds
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect()
}
/// Build nested tree items from a flat entry list sorted dirs-first.
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, so parents resolve in constant time while inserting.
let mut index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
for entry in entries {
let mut parent: Option<usize> = None;
let mut path = String::new();
for part in entry.components() {
let label = part.as_os_str().to_string_lossy().into_owned();
path = if path.is_empty() {
label.clone()
} else {
format!("{path}/{label}")
};
let ix = *index.entry(path.clone()).or_insert_with(|| {
let ix = nodes.len();
nodes.push((path.clone(), label.clone(), Vec::new()));
match parent {
Some(parent) => nodes[parent].2.push(ix),
None => roots.push(ix),
}
ix
});
parent = Some(ix);
}
}
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
let (id, label, children) = &nodes[ix];
TreeItemSeed {
id: id.clone(),
label: label.clone(),
children: children
.iter()
.map(|child| assemble(*child, nodes))
.collect(),
}
}
roots.iter().map(|root| assemble(*root, &nodes)).collect()
}
/// Sorted relative paths of a worktree snapshot.
///
/// Compared against the `worktree_paths` of a repository panel to skip
/// rebuilding the explorer when a refresh left the worktree unchanged.
pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
let mut paths: Vec<String> = entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
paths.sort();
paths
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_nested_tree_from_flat_entries() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
PathBuf::from("docs/guide.md"),
];
let items = build_tree_items(&entries);
// Input order is preserved, dirs-first as produced by worktree_entries.
assert_eq!(items.len(), 3);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].id, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "lib.rs");
assert_eq!(items[0].children[0].id, "src/lib.rs");
assert_eq!(items[1].label, "README.md");
assert_eq!(items[1].id, "README.md");
assert_eq!(items[2].label, "docs");
assert_eq!(items[2].children[0].label, "guide.md");
assert_eq!(items[2].children[0].id, "docs/guide.md");
}
#[test]
fn tree_builder_handles_deep_nesting() {
let entries = vec![
PathBuf::from("a"),
PathBuf::from("a/b"),
PathBuf::from("a/b/c.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 1);
assert_eq!(items[0].children[0].id, "a/b");
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
}
#[test]
fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories' entries.
// The worktree list is dirs-first globally.
// The shared prefix must still resolve to one node.
let entries = vec![
PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"),
PathBuf::from("a/z.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "a");
assert_eq!(items[0].children.len(), 2);
assert_eq!(items[1].label, "b");
}
}
-5
View File
@@ -29,7 +29,6 @@ impl Workspace {
let mut subscriptions = vec![];
// Sync the system appearance if the appearance mode is set to system.
if settings.read(cx).settings().appearance == AppearanceMode::System {
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
@@ -77,7 +76,6 @@ impl Workspace {
passphrase_dialog::open(window, cx);
}
// Open the sidebar and explore panel.
this.dock.update(cx, |dock_area, cx| {
dock_area.set_dock(
DockPlacement::Left,
@@ -88,7 +86,6 @@ impl Workspace {
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
});
// Open the explore panel.
weak_sidebar
.update(cx, |this, cx| {
this.open_explore(window, cx);
@@ -114,9 +111,7 @@ impl Render for Workspace {
.size_full()
.relative()
.child(self.dock.clone())
// Notifications
.children(notification_layer)
// Modals
.children(dialog_layer)
}
}
+1 -5
View File
@@ -17,14 +17,11 @@ fn main() {
gpui_component::init(cx);
theme::init(cx);
// Load the persisted settings before applying the theme.
// Stored appearance and theme settings then take effect at startup.
// The persisted settings must load before the theme is applied.
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
SettingsStore::set_global(store.clone(), cx);
let settings = store.read(cx).settings().clone();
// Register the built-in Signed theme, light and dark variants.
// The stored appearance then selects the active theme.
let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) {
@@ -72,7 +69,6 @@ fn main() {
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
// Connects relays and restores the session.
std::fs::create_dir_all(paths::nostr_dir()).ok();
std::fs::create_dir_all(paths::repos_dir()).ok();
signed_state::init(
File diff suppressed because it is too large Load Diff
-973
View File
@@ -1,973 +0,0 @@
# Inbox (home screen) implementation plan
Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user.
> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications`
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
> an account is active, and that home screen is the inbox.
> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to
> group **threads by repository** and to merge notifications with own activity into one row per thread
> (see the repository-grouping and thread-merge notes in §7).
> `cargo test -p signed_core` (69), `cargo test -p signed_state` (24),
> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets`
> clean, `cargo check --workspace --all-targets` succeeds.
> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1
> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the
> Phase 4 click-through, and the repository-grouped thread list. The Phase 3 bottom-dock sub-views were
> removed before the redesign; their implementation notes in §7 are historical.
## 1. What the GitWorkshop home screen is
`Index.tsx`:
```tsx
if (account) return <Dashboard />;
return <LandingPage />;
```
`Dashboard.tsx` layout:
- Desktop: two columns.
- **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`.
- **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`,
`FollowedReposPanel`.
- Mobile: a single column in a different order.
The panel that gives the screen its inbox identity is `NotificationsPanel`:
- heading **Notifications** with a bell icon and an unread count badge,
- a **Mark all read** action and a **View all** link to `/notifications`,
- a compact list of the first 5 **non-archived** notification items,
- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon).
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced
inline on the home screen. The home screen also shows your own recent activity and your repositories.
Data hooks:
| Section | Hook | What it loads |
|---|---|---|
| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state |
| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 |
| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you |
| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow |
| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services |
## 2. Scope for Signed
| Priority | Section | Notes |
|---|---|---|
| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown |
| **P1** | Click-through | Open the issue/PR detail panel at the relevant thread root |
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed |
Notes:
- There is **no greeting header**. The screen starts with the inbox panel.
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead.
## 3. The Signed screen
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered
card holding a single virtual list. Every row is either a **repository header** or one of that
repository's **threads**, newest first. A thread merges the notifications directed at the user with
the user's own events in the same root, and shows the root's title plus up to five of its most recent
events:
The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other
repository that has threads. Owned repositories with nothing to show render an
empty state ("No activity yet.") under their header, and sort after the ones with activity (newest
announcement first). Threads with no repository address fall into a single "Other repository" section.
```
+-------------------------------------------------------------------------+
| Inbox (3 unread) [Mark all read] |
|-------------------------------------------------------------------------|
| [repo] you/repo-a (2) |
| [icon] Add retry logic (unread dot) |
| [avatar] You opened an issue 3d |
| [avatar] alice commented 2d |
| [icon] Fix flaky test |
| [avatar] You opened a PR 1h |
|-------------------------------------------------------------------------|
| [repo] you/repo-b |
| No activity yet. |
|-------------------------------------------------------------------------|
| [repo] you/repo-c |
| No activity yet. |
+-------------------------------------------------------------------------+
```
The sections are the repositories that actually have threads, ordered by their
newest row. A repository the user owns but that has no items is not shown. Threads with no repository
address fall into a single "Other repository" section.
## 4. Data layer
### 4.1 `signed_core`: pure logic
**`filters.rs`** (extend, next to `activity`/`comments_for`):
```rust
/// Kinds that notify a user when they tag them directly.
pub const NOTIFICATION_KINDS: [Kind; 9] = [
Kind::GitIssue,
Kind::GitPullRequest,
Kind::GitPatch,
Kind::GitPullRequestUpdate,
COVER_NOTE_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
];
/// Comments on our issues/PRs/patches.
pub fn notification_comments(me: PublicKey) -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
}
/// Activity directed at us: comments on our roots, and git events tagging us.
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
/// Git activity authored by `me`, for "Continue where you left off".
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new()
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
.author(me)
}
```
`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs
(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`).
Comments authored by `me` are not all git comments, so the activity query needs a post-filter:
keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching
gitworkshop's `isGitComment`.
**`inbox.rs`** (new file):
```rust
pub struct InboxItem {
pub root: EventId,
/// The root event itself, when known locally; drives the row title.
pub root_event: Option<Event>,
pub root_kind: Option<Kind>,
pub address: Option<RepoAddr>,
/// Notification events directed at the user, newest first.
pub events: Vec<Event>,
/// The user's own events in the same thread, newest first.
pub own_events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
impl InboxItem {
/// Title of the thread root; falls back to the newest event it has.
pub fn title(&self) -> String;
/// Kind of the thread root; falls back to the newest event it has.
pub fn kind(&self) -> Option<Kind>;
pub fn latest_activity(&self) -> Timestamp;
/// Up to `limit` most recent events of the thread, oldest first.
pub fn timeline(&self, limit: usize) -> Vec<Event>;
pub fn is_unread(&self) -> bool;
pub fn apply_state(&mut self, state: &InboxReadState);
}
/// The thread root of a notification event, or `None` if it isn't git-related.
pub fn notification_root(
event: &Event,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Option<EventId>;
/// Group the notifications directed at the user together with the user's own
/// events into one item per thread, newest activity first.
pub fn group(
events: impl IntoIterator<Item = Event>,
own: impl IntoIterator<Item = Event>,
me: PublicKey,
state: &InboxReadState,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Vec<InboxItem>;
```
Root resolution, ported from `getNotificationRootId`:
- issue (1621) / PR (1618): itself
- patch (1617): its `e` parent patch, else itself
- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`)
- PR update (1619): uppercase `E`
- statuses (1630-1633) / cover note (1624): NIP-10 root `e`
- notification events authored by `me` are dropped; the user's own events are kept in
`own_events` instead, never in `events`
- `unread_ids` and `archived` are derived from `events` only, so the user's own activity is never
unread and a thread with only own events is never archived
Read/archive state, the compact high-water-mark model:
```rust
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)] pub read_before: Timestamp,
#[serde(default)] pub read_ids: HashSet<EventId>,
#[serde(default)] pub archived_before: Timestamp,
#[serde(default)] pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
pub fn is_read(&self, event: &Event) -> bool;
pub fn is_archived(&self, event: &Event) -> bool;
pub fn mark_read(&mut self, event: &Event);
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
}
```
`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first
line; reuse it for the home screen rows.
### 4.2 Persistence: NIP-78 in the local database, never published
Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event
**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device.
It is signed with a **random keypair**, never the user's signer. The event is local application
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
the signer type. The `d` tag identifies the owning user, so state does not leak across identities
when the signed-in key changes.
```rust
/// d tag identifying the inbox read/archive state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored read state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
// No author filter: the signing key is random per save.
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(inbox_state_d_tag(me));
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
}
/// Sign with a fresh random key and store locally.
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
client.database().save_event(&event).await?;
Ok(())
}
```
A fresh random key is generated on every save, so each save writes a new event rather than
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
id across saves; that was removed as more derived state than it was worth.
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
The inbox is split in two, because the expensive derivation is only needed while the home screen is
open.
**`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is
deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78
load/save.
```rust
// backend.rs
pub struct Backend {
...
inbox: Entity<Inbox>,
}
// inbox.rs
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
loaded: bool,
}
impl Inbox {
pub fn state(&self) -> &InboxReadState;
pub fn is_loaded(&self) -> bool;
pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx);
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx);
pub(crate) fn reset(&mut self, cx);
}
// inbox.rs (signed_state)
/// One item per thread, notifications and own activity merged.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, usize), Error>;
```
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
state they were computed with, and the refresh coalescing. There is no separate store entity: the
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header
badge only; there is no global count and no sidebar badge.
```rust
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
threads: Arc<Vec<InboxItem>>, // one row per thread, merged
sections: Arc<Vec<InboxSection>>, // grouped by repository
rows: Arc<Vec<InboxRow>>, // flattened list
unread_count: usize,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
list: ListState,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>); // cx.defer(… sync_state)
pub fn sync_state(&mut self, cx); // observes the global Inbox
pub fn mark_all_read(&mut self, cx);
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
fn refresh(&mut self, cx);
fn run_refresh(&mut self, cx);
fn regroup(&mut self, cx); // re-apply read state
fn rebuild(&mut self, cx); // group by repository, seed owned, flatten
fn clear(&mut self);
}
```
The panel owns three subscriptions that carry logic: it observes the global `Inbox`
(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes
`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no
subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks
`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back
to the global.
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
re-exported for the panel's debounce.
```rust
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
```
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
`signed_state::init` gains no parameters.
**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count
derivation was removed with it). The unread count lives entirely in the panel, which shows it in
its header and per repository section. The trade-off is that the count is only current while the
panel is open, which is acceptable now that nothing outside it displays one.
The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`.
`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for
its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation
directly.
`InboxView::handle_backend_event` refreshes on:
- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
a deletion (`EventDeletion` / `RequestToVanish`).
- `Synced` / `Published`.
- everything else: ignored.
**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the
three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore)
and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or
`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend`
while `sync_inbox` is mid-update:
```rust
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
let me = self.current_user;
if let Some(me) = me {
self.subscribe_bootstrap(filters::notifications(me), cx);
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
.read(cx)
.announcements_of(&me)
.into_iter()
.flat_map(|announcement| announcement.relays)
.collect();
if !relays.is_empty() {
let relays: Vec<RelayUrl> = relays.into_iter().collect();
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
}
}
let client = self.client.clone();
self.inbox.update(cx, |inbox, cx| match me {
Some(me) => inbox.activate(me, client, cx),
None => inbox.reset(cx),
});
}
```
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
/ `connect_own_repo_relays`.
**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
```rust
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
// state = default; state_loaded = false; cx.notify();
// spawn load_state(client, me), then set state and state_loaded = true
}
```
`reset` performs the same clearing without a state load, and is used on logout.
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
still gated on the signer because the fetch filters need the user's pubkey.
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
read, not a wait on the network; see the note below.
**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
- `cx.background_spawn`: query the notification filters and the activity filter from
`client.database()`.
- Query `filters::deletions()`, build `Deletions`, skip deleted events.
- Build `HashMap<EventId, Event>` for root walking; group the notification events with
`inbox::group`.
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their `K` tag is a git kind; sort newest first.
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the
repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`.
`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the
lists, on the first load it runs the initial refresh, and on a state change (a mark action) it
re-derives the flags (`InboxItem::apply_state`).
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
so the query that follows them reads the local cache rather than waiting for the relays. That is
deliberate offline-first behavior: cached content appears at once on a warm start and with no
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
reviewed and left as-is.
**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the
global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound
the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The
panel then re-derives and publishes the unread count.
**Repository names need no new store**: `RepoListStore` already holds every announcement and
`repo_name` resolves an address to a display name.
### 4.4 `Cargo.toml`
- `signed_core`: add `serde.workspace` for the `InboxReadState` derives.
- `signed_state`: add `serde_json.workspace` for the NIP-78 content.
## 5. UI
### 5.1 `InboxView` center panel
`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one
bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single
`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a
`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height.
The list count is reset from `render` whenever the rendered row count changes.
- **Header**: the unread count badge and **Mark all read**.
- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon,
the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows
under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an
unread dot (the subject is semibold while unread). A repository with nothing to show renders
"No activity yet."; the panel-level "You're all caught up." empty state appears only when there are
no sections at all (no owned repositories and no items).
No greeting header, and no **My repositories** column - the sidebar already lists the user's
repositories.
### 5.2 Grouping by repository
The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called
from `run_refresh` and `regroup`), never per frame:
```rust
struct InboxSection {
address: Option<RepoAddr>, // repository, None for items without one
unread: usize, // unread notification groups
entries: Vec<InboxEntry>, // newest first
latest: Timestamp, // orders the sections
}
enum InboxEntry { // indices into the panel's own lists
Notification(usize),
Activity(usize),
}
enum InboxRow { // the flattened list
Repo(usize),
Entry(usize, usize),
Empty, // "No activity yet." under an empty section
}
```
The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list`
closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies.
Notification groups carry their repository in `InboxItem::address`; activity events carry it in a
`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out.
`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes
`RepoListStore` so a repository that loads after the last refresh still appears (its own empty
section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and
`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a
late announcement still labels its section without re-deriving the grouping.
### 5.3 Sidebar
In `views/sidebar/mod.rs`:
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds
a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab).
`InboxView::new` takes the sidebar's `WeakEntity<DockArea>` so the panel can open a repo for a row.
- Point the existing nav item at it:
```rust
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
```
- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global
`Inbox`. The unread count lives in the panel only.
### 5.4 Click-through (P1)
The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`,
which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than
a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`:
```rust
pub(crate) enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>,
announcement: &Announcement,
item: RepoItem,
window: &mut Window,
cx: &mut App,
) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ }
```
- `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls
`RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier
version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that
docked the repository panel too, which surfaced the repository load state (a `not found` error for
an announced repo with no local worktree) and left two center tabs. `RepoDetailView::store()` was
removed with it.
- `views/mod.rs` re-exports `RepoItem` and `open_repo_item`.
- `InboxView::open_item` resolves `item.address` to an `Announcement` from `RepoListStore`, and calls
`open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder
until the store's fetch lands, then re-renders.
- The item panel is added to the center group and activated.
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
patch-root click opens nothing. `RepoItem::Patch` carries no id for that reason. A group whose root is
not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing.
## 6. File-by-file change list
| File | Change |
|---|---|
| `crates/signed_core/Cargo.toml` | add `serde` |
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem` (root event, notifications, own events), `notification_root`, `group`, `InboxReadState`, tests |
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
| `crates/signed_state/Cargo.toml` | add `serde_json` |
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, merge notifications + activity into threads) |
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import |
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) |
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) |
| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread click-through |
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` |
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring |
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item` (builds its own `RepoStore` via the private `repo_store` helper) |
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
activates the `Inbox` child entity at each signer transition.
## 7. Phasing
1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.**
Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}`
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
and `authored_activity` results must pass through `is_git_activity` before display (comments on
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
end of Phase 0; 68 after the two Phase 1 additions).
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
implementation notes below.
3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item.
**DONE.** See the implementation notes below.
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
**Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the
notes below are historical.
5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation
notes below.
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
view.
Each phase compiles and is usable on its own.
### Phase 1 implementation notes
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
and two additions to `crates/signed_core/src/inbox.rs`.
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
a re-entrant entity access.
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
`SidebarPanel`, but no longer drive the inbox.
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
before the stored state is read would briefly mark everything unread. The first refresh is chained
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
user the query was started for.
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
Both are covered by tests.
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
rather than a single hop, because a patch series chains through parent patches. Only the notification
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
not mistaken for a notification.
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
re-derives the groups locally so the UI updates immediately, then persists in the background.
- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
either: the newest state event is selected by `created_at`.
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
### Phase 2 implementation notes
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
was later folded into the panel - see the store-merge note below.)
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
`gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
repositories, so the panel is a single column.
- Notification rows read the newest event of each group for the actor, subject and time, and the
root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of
`RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged.
- The **Unread** / **Archived** header buttons are intentionally absent: they need
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
so the panel is fully usable on its own.
- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the
"idle until signer" rule; auto-opening it as the post-login home is a possible follow-up.
- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was
removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`,
`open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports.
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass.
### Architecture refactor (after Phase 2)
Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran
whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to
feed it.
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save
and the mark actions.
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
- The panel became the client-side owner of the derivation, initially through a panel-scoped
`Entity<InboxStore>`.
- `signed_core` is unchanged.
### Store merged into the panel (after Phase 2)
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
methods. The two subscriptions call them directly, with no `update` indirection.
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
until the Phase 3 sub-views wire them up.
- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace`
(7) pass; clippy and `cargo check --workspace --all-targets` are clean.
Trade-off: the unread count is derived by the panel, so it is only current while the panel is open.
(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar
badge removed" below.)
### Phase 3 implementation notes
> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single
> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale.
Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No
store changes.
- `add_bottom_panel` sits next to `add_center_panel` and wraps
`DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open,
and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock,
so a closed sub-view leaves no strip behind.
- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity<InboxView>` (strong; the
panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own
`ListState`. There is no subscription: it reads the inbox entity during render, which is enough for
GPUI to invalidate the window when the inbox notifies.
- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title
comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of
opening a second one.
- `InboxView` regained a `dock_area: WeakEntity<DockArea>` (removed with the My-repositories column)
and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom
dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called
as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`.
- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone:
Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button
(`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()`
so it does not also trigger the row's mark-read click. Archived rows are display-only; the read
state has no un-archive operation.
- `notification_row` takes an id `prefix` and returns `Stateful<Div>` rather than `AnyElement`, so
callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and
the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would
otherwise collide on `(str, ix)` ids.
- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets`
succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7).
### Phase 4 implementation notes
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes.
- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live
in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from
the announcement (private `repo_store` helper), so only the item panel is docked.
- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and
a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is
not available. `InboxView` already has the window in the list's `on_click`, so it drives the free
function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile.
- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures
from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves
`item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the
root kind to a `RepoItem`, and calls `open_repo_item`.
- Fixed: the first version opened `RepoDetailView` to borrow its store (`RepoDetailView::store()`),
which docked the repository panel alongside the item panel and showed its `not found` load error.
`open_repo_item` now builds the `RepoStore` itself and `RepoDetailView::store()` is gone.
- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read /
archive row behaviour is gone with the sub-views.
- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so
`open_repo_item` returns before doing anything and nothing is docked.
- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds,
and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
### Repository grouping redesign (after Phase 4)
Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes.
The two-card layout (notifications over activity) was replaced by a single repository-grouped list.
- The panel now derives `sections: Vec<InboxSection>` and a flattened `rows: Vec<InboxRow>` in
`rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the
`gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no
data duplicated between the section list and the source lists.
- `InboxSection` groups a repository's non-archived notification groups and the user's own activity,
newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the
panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the
list renders.
- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`,
so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts
after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's
repositories load or change.
- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`).
Items without an address share the "Other repository" section.
- `notification_row` / `activity_row` no longer render the repository name - the section header does.
That also drops one `RepoListStore` scan per row.
- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the
`render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still
takes an id prefix so rows stay unique within the list.
- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state
-p workspace -p dock` passes (68 / 24 / 7 / 1).
### Sidebar badge removed (after the repository grouping redesign)
Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`,
`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`.
An intermediate change made the sidebar badge live by moving the unread count into the global
`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge
itself, so the global is thin again.
- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and
its observe of the global `Inbox`.
- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` /
`refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count
refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it.
- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for
the repository headers; `publish_unread_count` stays deleted.
- Consequence: the unread count is only current while the panel is open, and there is no unread
indication anywhere else in the app.
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
`cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
### Threads merged: notifications + activity (after the sidebar badge removal)
Files: `crates/signed_core/src/inbox.rs`, `crates/signed_state/src/inbox.rs`,
`crates/workspace/src/views/inbox.rs`.
Notifications and own activity were two separate row kinds that could describe the same thread. They
are now one item per thread: the notifications directed at the user and the user's own events in that
thread live in the same `InboxItem`. A row shows the thread root's title and up to five of the
thread's most recent events:
```
[icon] Add retry logic (unread dot)
[avatar] You opened an issue · 3d
[avatar] alice commented · 2d
```
- `InboxItem` gained `root_event: Option<Event>` and `own_events: Vec<Event>`. `events` keeps only the
notifications (others' events); `own_events` holds the user's own. `unread_ids`/`archived` are
derived from `events` alone, so own activity is never unread and a thread with only own events is
never archived (`apply_state` guards the empty case).
- New methods on `InboxItem`: `title()` (root event's subject, falling back to the newest event),
`kind()` (root kind, same fallback), and `timeline(limit)` (thread events deduplicated by id,
oldest first, always keeping the root event and filling the remaining slots with the most recent
others).
- `group` now takes both `events` (notifications) and `own` (the user's activity) and merges them on
the resolved root. Own events resolve through the same `notification_root`; an unresolved own event
becomes its own root. `query_inbox` returns `(Vec<InboxItem>, usize)` - the separate activity list
is gone, and `by_id` is extended with the own events so a comment of ours resolves to its thread.
- The panel holds `threads: Arc<Vec<InboxItem>>` instead of `notifications` + `activity`. The
`InboxEntry` enum, `entry_time`, `repo_address`, `related_activity`, `notification_row`,
`activity_row` and `kind_label` are gone. `thread_row` replaces both row kinds and is clickable like
the old notification row; `group_sections` now just buckets threads by `item.address`.
- `sub_activity_line` is unchanged and still renders `[avatar] [name] [phrase] · [ago]`, with `You`
for the signed-in user and `activity_phrase(kind)` for the verb. Rows are variable height
(`py_2`), which `gpui::list` auto-measures.
- Thread rows in a section are drawn as one stack: `render_entry` passes `first`/`last` within the
section (`entry_ix == 0` / `entry_ix + 1 == section.entries.len()`), and `thread_row` rounds the
outer edges (`rounded_t` on the first, `rounded_b` on the last, theme radius) and draws a
`border_b_1` divider on every row but the last.
- Trade-off: the row title is the thread root's, not the newest event's, so a comment thread no longer
previews the comment text. That is the point of the merge - the row identifies the thread.
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
`cargo test -p signed_core -p signed_state -p workspace` passes (69 / 24 / 7).
## 8. Validation
- `cargo test -p signed_core` (69 tests): root resolution, grouping, merging, read-state cutoff, serde
round-trip.
- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI
(state round-trip, grouping helpers).
- `cargo test -p workspace` (7 tests): repository-detail helpers.
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
`cargo check --workspace --all-targets` after each phase.
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
populates from another identity's issue/comment, the activity list shows your own items, and that no
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
state is read back from LMDB. Confirm the sidebar has no unread badge.
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,`
`GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}`
- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}`
- Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use
`Filter::author`/`authors` for authorship. The `notifications` filter relies on this.
- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}`
- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers
- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators
- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`;
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
`client.sync(...)` both persist received events to LMDB and surface them as
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
This is why the query right after a fetch is a cache read, not a race.
+291
View File
@@ -0,0 +1,291 @@
# Repo view refactor plan
## Goal
Make `crates/workspace/src/views/repo/` easy to navigate and change:
- Each concern owns its own state (its own struct fields), instead of all concerns sharing one 38-field struct.
- Shared UI moves to the module that consumes it, so sibling views stop importing from `views::repo`.
- No behavior change. No new global state. No new store. The GPUI patterns already used in the repo (`Entity` + observe, `TreeState`, `ComboboxState`, `VirtualListScrollHandle`) stay the only patterns used.
## Constraints
- Follow `.rules`: no `unwrap` in production, no silently discarded errors, full-word names, comments explain "why" only.
- Do not over-engineer. Files and History become entities because they already render and run async independently. Refs and Banners stay plain field groups on the shell.
- Keep the shell as the single load/reconcile point. The clone/worktree and `ref_generation` belong to the shell, not to child views.
- `signed_ui` does **not** depend on `signed_git` (verified in `crates/signed_ui/Cargo.toml`). Anything that takes a `signed_git` type cannot move there.
## Current state (verified)
| File | Lines | Content |
|---|---|---|
| `mod.rs` | ~376 | `RepoDetailView` struct (38 fields), constructors, `render`, panel impls, `display_name` |
| `store.rs` | ~98 | `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` |
| `actions.rs` | ~208 | action methods + `open_repo_panel` / `open_repo_item` free functions |
| `loading.rs` | ~435 | `load_repo`, `apply_repo_data`, `sync_ref_selector`, `clone_to_folder`, `load_repo_data` |
| `refs.rs` | ~279 | `switch_ref`, `restore_selection`, `reload_worktree`, `catch_up_worktree` |
| `files.rs` | ~480 | file tree, previews, markdown/code state, eviction |
| `history.rs` | ~228 | commits tab render + per-file and full commit walks |
| `header.rs` | ~715 | header render, maintainers, fork row, clone URL |
| `banners.rs` | ~315 | ready/push suggestion banners |
| `about.rs` | ~224 | about dialog |
| `init_dialog.rs` | ~202 | publish-to-NIP-34 dialog |
| `helpers.rs` | ~722 | `pub(crate)` grab bag: tree building, diff rendering, discussion UI, share targets, commit rows |
### Problems
1. **`RepoDetailView` is a god object.** 38 fields across six concerns. All 12 files are `impl RepoDetailView`, so any file can read/write any field. The file split added navigation cost without encapsulation.
2. **`helpers.rs` is an inverted dependency hub.** `views/issues/detail.rs` and `views/pull_requests/*.rs` import from `views::repo::helpers` for discussion UI, diff rows and commit rows. Sibling views reaching into `repo` is backwards.
3. **Two regions have independent async + render lifecycles** (file browser, commit history) but live as shell fields, sharing `worktree` and `ref_generation` by hand.
### Existing good pattern
`IssuesView` (`views/issues/mod.rs`): own struct (~13 fields), `cx.observe(&store, ..)`, `rebuild()` into local state, `Render`, no shell fields. The refactor brings `RepoDetailView` in line with this.
## Target structure
```mermaid
graph TD
Shell["RepoDetailView shell\nstore, dock_area, tabs, header,\nload orchestration, worktree, generation"] --> Files["Entity<RepoFilesView>\nfiles.rs"]
Shell --> History["Entity<RepoHistoryView>\nhistory.rs"]
Shell --> Refs["RefSwitcher (plain)\nrefs.rs"]
Shell --> Banners["Banners (plain)\nbanners.rs"]
Files --> Store["Entity<RepoStore>"]
History --> Store
```
Field ownership after the refactor:
| Concern | Fields | Owner |
|---|---|---|
| Files | `tree_state, worktree_paths, md, code, readme_name, selected_file, files, file_order, preview_bytes, loading_files, commits, pending_commits, loading_commits` | `RepoFilesView` |
| History | `all_commits, loading_all_commits, item_sizes, scroll_handle` | `RepoHistoryView` |
| Refs | `branch_select, tag_select, ref_branches, ref_tags, switching_ref` | `RefSwitcher` |
| Banners | `banner_dismissed, ready_requested, ready_head, ready_statuses, push_statuses` | `Banners` |
| Shell | `focus_handle, dock_area, store, repo_started, active_tab, loading, error, head_commit, worktree, ref_generation, _subscriptions` | `RepoDetailView` (11 fields) |
Shared modules after Phase 1:
| New / changed module | Contents | Consumers |
|---|---|---|
| `views/tree.rs` | `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths` + the 3 tree tests | repo files/loading, commit_diff |
| `views/commit_diff/mod.rs` | adds `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `commit_row`, `COMMIT_ROW_HEIGHT` | commit_diff, PR new, repo history |
| `views/discussion.rs` | `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots` | issues detail, PR detail |
| `signed_ui/src/ref_selector.rs` | `ref_selector_trigger` | repo header, PR new |
| `repo/files.rs` | `code_language`, `is_markdown_path` (only used there) | repo files |
| `repo/header.rs` | `ShareTargets`, `truncate_naddr_link` (only used there) | repo header |
`views/repo/helpers.rs` is deleted at the end of Phase 1.
---
## Phase 0 - baseline
No code. Record the current state so each later phase can be compared.
1. `cargo fmt --all -- --check`
2. `cargo check --offline --workspace --all-targets`
3. `cargo test --offline -p workspace`
4. `cargo clippy --offline -p workspace --all-targets`
Do not run plain `cargo` without `--offline`; the sandbox fails the git fetch and it looks like a dependency error.
---
## Phase 1 - extract shared modules (dissolve `helpers.rs`)
Low risk, no state moves. Land it as one commit.
### 1.1 Create `crates/workspace/src/views/tree.rs`
Move from `repo/helpers.rs`: `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths`, and the three tests (`builds_nested_tree_from_flat_entries`, `tree_builder_handles_deep_nesting`, `tree_builder_merges_shared_prefixes`).
- Add `pub(crate) mod tree;` to `views/mod.rs`.
- Update imports in `repo/loading.rs`, `repo/refs.rs`, `commit_diff/mod.rs` to `crate::views::tree::...`.
### 1.2 Move diff and commit-row rendering into `views/commit_diff/mod.rs`
Move from `repo/helpers.rs`: `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `COMMIT_ROW_HEIGHT`, `commit_row`.
- `commit_diff/mod.rs` already owns `DiffPane` and depends on `signed_git`, so this is its natural home and keeps `signed_ui` free of a `signed_git` dependency.
- Update imports in `views/pull_requests/new.rs` and `repo/history.rs`.
### 1.3 Create `crates/workspace/src/views/discussion.rs`
Move from `repo/helpers.rs`: `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots`.
- Add `pub(crate) mod discussion;` to `views/mod.rs`.
- Update imports in `views/issues/detail.rs` and `views/pull_requests/detail.rs`. After this, neither imports from `views::repo`.
### 1.4 Move `ref_selector_trigger` into `signed_ui`
It takes `CustomIconName` (from `assets`) and `ComboboxTriggerContext` (from `gpui_component`); both are already `signed_ui` dependencies, so no dependency changes.
- Add `crates/signed_ui/src/ref_selector.rs`, export it from `lib.rs`.
- Update imports in `repo/header.rs` and `views/pull_requests/new.rs`.
### 1.5 Move `code_language` and `is_markdown_path` into `repo/files.rs`
Only `repo/files.rs` uses them. Keep them private there.
### 1.6 Move `ShareTargets` and `truncate_naddr_link` into `repo/header.rs`
Only `repo/header.rs` uses them. Keep them private there.
### 1.7 Delete `repo/helpers.rs`
Remove `pub(super) mod helpers;` from `repo/mod.rs`. Confirm no `use ...repo::helpers` remains anywhere:
```
grep -rn "repo::helpers" crates/workspace/src
```
### Phase 1 validation
`cargo fmt --all`, `cargo check --offline -p workspace --all-targets`, `cargo test --offline -p workspace`, `cargo clippy --offline -p workspace --all-targets`.
---
## Phase 2 - extract `Entity<RepoFilesView>`
Largest win: removes 14 fields and most of the preview logic from the shell.
### 2.1 Define the view
In `repo/files.rs`, replace `impl RepoDetailView` with `pub(super) struct RepoFilesView` holding: `tree_state`, `worktree`, `worktree_paths`, `md`, `code`, `readme_name`, `selected_file`, `files`, `file_order`, `preview_bytes`, `loading_files`, `commits`, `pending_commits`, `loading_commits`.
Move the supporting types and helpers from the current `files.rs` into the view: `FileContent`, `MarkdownView`, `CodeView`, `MAX_PREVIEW_BYTES`, `MAX_PREVIEWED_FILES`, `MAX_PREVIEW_CACHE_BYTES`, `source_hash`, `preview_spinner`, `render_tree_item`, `render_tree_column`, `render_content_column`, `set_markdown`, `markdown_element`, `set_code`, `code_element`, `open_file`, `drop_preview_of`, `evict_previews`.
Move from `repo/history.rs`: `load_commit`, `load_commits` (the per-file commit map).
### 2.2 Define the view's interface
- `pub(super) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self` - creates the `TreeState`.
- `pub(super) fn set_worktree(&mut self, path: PathBuf)`.
- `pub(super) fn apply_entries(&mut self, tree: Vec<TreeItemSeed>, paths: Vec<String>, window, cx)` - used by `load_repo` / `reload_worktree` / `catch_up_worktree`.
- `pub(super) fn set_readme(&mut self, path: Option<PathBuf>, bytes: Option<Vec<u8>>, cx)`.
- `pub(super) fn clear_previews(&mut self)` - branch switch.
- `pub(super) fn catch_up(&mut self, snapshot, window, cx) -> bool` - rebuild tree, drop removed previews, re-render README; returns whether anything changed.
- `impl Render for RepoFilesView`.
- `pub(super) fn pane_title(&self) -> SharedString` - `selected_file` or `readme_name` or `"Overview"`.
### 2.3 Move the clone loading/error display out of the file view
`render_content_column` currently shows "Cloning repository..." / a load error from `self.loading` and `self.error`, which are shell state. Move that decision to the shell's `render`: while `self.loading`, render a spinner in the tab body; when `self.error` is set, the existing `Alert` already covers it. `render_content_column` then handles only file previews and the README.
### 2.4 Wire the shell
- Add `files: Entity<RepoFilesView>` to `RepoDetailView`.
- In `new_common`, `let files = cx.new(|cx| RepoFilesView::new(window, cx));`.
- In `render`, the Files tab body becomes `self.files.clone()`.
- In `load_repo` (`loading.rs`) and `reload_worktree` / `catch_up_worktree` (`refs.rs`), replace direct field writes with calls on `self.files`.
- Remove the now-unused `files.rs` imports from `mod.rs` and the moved fields from the struct and constructor.
### Phase 2 validation
Same commands. Manual: open explore repo, click files in the tree, open the README, switch branch (previews clear), switch back, confirm no spinner sticks.
---
## Phase 3 - extract `Entity<RepoHistoryView>`
### 3.1 Define the view
In `repo/history.rs`, replace the commits-tab methods with `pub(super) struct RepoHistoryView` holding: `store: Entity<RepoStore>`, `dock_area: WeakEntity<DockArea>`, `worktree: Option<PathBuf>`, `all_commits`, `loading_all_commits`, `item_sizes`, `scroll_handle`.
Move: `render_commits_tab` (becomes `impl Render`), `load_all_commits`, `open_commit_diff`.
### 3.2 Display name
`open_commit_diff` uses the shell's `display_name`. Extract the `display_name` logic from `RepoDetailView` into a free function in `repo/mod.rs`:
```rust
pub(super) fn repo_display_name(store: &RepoStore) -> SharedString
```
It keeps the local-path fallback that `RepoStore::name()` does not have. Use it in the shell's `Panel::title`, in the header, and in `RepoHistoryView::open_commit_diff`.
### 3.3 Interface
- `pub(super) fn new(store, dock_area, window, cx) -> Self`.
- `pub(super) fn set_worktree(&mut self, path: Option<PathBuf>)`.
- `pub(super) fn reload(&mut self, cx)` - clears `all_commits` and starts the walk (called when HEAD changes or the branch switches).
- `impl Render for RepoHistoryView`.
### 3.4 Wire the shell
- Add `history: Entity<RepoHistoryView>` to `RepoDetailView`; create it in `new_common`.
- In `render`, tab 1 becomes `self.history.clone()`.
- Replace `self.all_commits` / `self.loading_all_commits` / `self.item_sizes` writes in `load_repo`, `reload_worktree`, `catch_up_worktree`, and the header-commit pill path with `self.history.update(..)` calls.
- Remove the moved fields from the struct and constructor.
### Phase 3 validation
Same commands. Manual: open the Commits tab, scroll a long history, click a commit (diff panel opens), switch branch and confirm the list reloads.
---
## Phase 4 - group `RefSwitcher` and `Banners`
Plain structs on the shell. No entity, no observer changes.
### 4.1 `RefSwitcher`
Move into a `struct RefSwitcher { branch_select, tag_select, ref_branches, ref_tags, switching_ref }` field on the shell. Update `refs.rs` and `loading.rs` methods to read/write `self.refs.*`. `switch_ref` stays on the shell because it fans out to files, history and `head_commit`.
`ref_generation` stays on the shell: it is shared with the files and history loads.
### 4.2 `Banners`
Move into a `struct Banners { dismissed, ready_requested, ready_head, ready_statuses, push_statuses }` field. `banners.rs` and `store.rs` methods keep their `impl RepoDetailView` shape but read/write `self.banners.*`.
### Phase 4 validation
Same commands. Manual: the ready-to-contribute banner appears and dismisses, the push banner appears for an owned repo, dismissing survives a store refresh.
---
## Phase 5 - fold `store.rs` and tidy
1. Move `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` into `mod.rs` and delete `repo/store.rs`.
2. Remove `mod store;` from `repo/mod.rs`.
3. Confirm `mod.rs` reads as a shell: struct, constructors, load coordination, `render`, panel impls.
4. Final validation:
```
cargo fmt --all
cargo check --offline --workspace --all-targets
cargo test --offline --workspace
cargo clippy --offline --workspace --all-targets
```
## Validation (manual smoke, after each phase)
- Open a repo from the explore list, then open an issue and a PR.
- Deep-link straight to an issue / PR without visiting the repo panel.
- Open a local repository (never announced).
- Initialize a local repo to NIP-34, confirm it leaves the sidebar's local section.
- Clone to folder; clone again before the first clone completes.
- Switch a branch and a tag; confirm previews and the commit list reset.
- Owned repo with unpushed commits: push banner, push, republish banner.
## Boundary test for "done"
- No file can touch fields it does not own.
- `repo/mod.rs` is a shell, roughly 200 lines.
- `grep -rn "views::repo::helpers" crates/workspace/src` returns nothing.
- `views/issues` and `views/pull_requests` have no `use ...views::repo`.
## Non-goals
- No behavior change; no UI redesign.
- No new global state, no new store, no changes to `signed_state` or `dock`.
- No more `impl RepoDetailView` chapters. New files own structs, not fragments of one struct.
- Do not move `commit_row` into `signed_ui`: it takes `signed_git::FileCommit` and `signed_ui` does not depend on `signed_git`.
## Risks and open questions
- **Async generation.** `ref_generation` discards stale loads. It stays on the shell; when the shell pushes a snapshot into a child view, the child must not start a new load that outlives the generation. Simplest rule: only the shell starts loads, child views only render and own per-file preview fetches keyed to the current worktree.
- **Files owns the per-file commit walk.** `load_commit`/`load_commits` move with the preview state, so the shell no longer coordinates them. Confirm the README commit lookup still works after the move.
- **History is small.** After moving `load_commit`/`load_commits` to Files, `history.rs` is ~150 lines. If an entity feels heavy for that, a plain `struct History` field is an acceptable fallback; the field ownership still improves.
- **`RepoStore::name()` vs `display_name`.** `RepoStore::name()` returns `Unknown` for local repos. The extracted `repo_display_name` must keep the local-path fallback so titles are unchanged.