clean up
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ impl SignedTilesSkin {
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge or corner handle.
|
||||
fn resize_handle(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,3 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -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-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<'_>>,
|
||||
|
||||
@@ -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)?)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,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.
|
||||
///
|
||||
@@ -86,14 +85,12 @@ 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,
|
||||
/// Backend subscription of an announced repository. `None` while local-only.
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl RepoStore {
|
||||
/// Announced repository.
|
||||
pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
let subscription = Self::subscribe_backend(cx);
|
||||
@@ -184,7 +181,6 @@ impl RepoStore {
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Subscriptions to the backend events concerning this repository.
|
||||
fn subscribe_backend(cx: &mut Context<Self>) -> Subscription {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
@@ -231,7 +227,6 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the repository's NIP-34 address. `None` while it is local-only.
|
||||
pub fn addr(&self) -> Option<&RepoAddr> {
|
||||
self.addr.as_ref()
|
||||
}
|
||||
@@ -287,7 +282,6 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -586,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
|
||||
}
|
||||
@@ -618,7 +610,6 @@ impl RepoStore {
|
||||
.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);
|
||||
@@ -636,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()
|
||||
@@ -675,7 +665,6 @@ impl RepoStore {
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a pull request on this repository.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_pull_request(
|
||||
&mut self,
|
||||
@@ -1203,7 +1192,6 @@ impl RepoStore {
|
||||
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;
|
||||
@@ -1302,7 +1290,6 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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!(
|
||||
@@ -1347,7 +1334,6 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the unpushed commits of the local checkout at `path`.
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -1477,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);
|
||||
@@ -1495,13 +1480,11 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Record that an action needs a NIP-34 address this repository does not have.
|
||||
fn not_announced(&mut self, cx: &mut Context<Self>) {
|
||||
self.last_error = Some("This repository is not published to Nostr yet".into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Fail an operation whose announcement is not loaded yet.
|
||||
fn action_error(
|
||||
&mut self,
|
||||
message: impl Into<String>,
|
||||
@@ -1625,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)
|
||||
@@ -1633,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],
|
||||
@@ -1800,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();
|
||||
@@ -1842,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -154,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()
|
||||
}
|
||||
@@ -169,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();
|
||||
@@ -238,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);
|
||||
|
||||
@@ -261,7 +251,6 @@ impl RepoListStore {
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the refresh entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,22 +25,15 @@ use crate::views::repo::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, 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 +49,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 +77,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 +87,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 +105,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 +121,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 +150,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 +167,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 +293,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 +336,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 +383,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| {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
@@ -445,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?;
|
||||
@@ -457,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"));
|
||||
@@ -481,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()
|
||||
@@ -600,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";
|
||||
@@ -620,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()
|
||||
|
||||
@@ -15,13 +15,10 @@ use utils::relative_time;
|
||||
|
||||
use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Repo store holding the issues and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -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,28 +46,17 @@ 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>>>,
|
||||
/// 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),
|
||||
/// Filter [`Self::visible_issues`] was last rebuilt for.
|
||||
///
|
||||
/// A filter change notifies even when the visible rows are unchanged,
|
||||
/// e.g. switching between two empty filters.
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: IssueFilter,
|
||||
/// Virtual list state of the issues list.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Rebuild the rows and re-render when the store's data changes.
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -235,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()
|
||||
@@ -293,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..."));
|
||||
|
||||
@@ -33,14 +33,11 @@ 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};
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Root PR inputs one diff load is keyed to.
|
||||
///
|
||||
/// A store refresh re-binds the panel, and reloads only when these change.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct PrBinding {
|
||||
@@ -56,22 +53,16 @@ struct PrBinding {
|
||||
/// 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>,
|
||||
@@ -82,17 +73,13 @@ pub struct PullRequestDetailView {
|
||||
bound: Option<PrBinding>,
|
||||
/// Generation of the in-flight diff load. Stale results are discarded.
|
||||
load_generation: u64,
|
||||
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
/// 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,
|
||||
/// Re-render when the store's first pass or a later refresh lands.
|
||||
/// Item panels are cached by the dock, so without this observer a panel
|
||||
/// opened before the store loaded would stay on its placeholder.
|
||||
/// The dock caches item panels, so without this observer a panel opened
|
||||
/// before the store loaded would stay on its placeholder.
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -523,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()
|
||||
@@ -573,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,
|
||||
@@ -628,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) = {
|
||||
@@ -730,7 +710,6 @@ impl PullRequestDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the update pull request dialog.
|
||||
fn open_update_pull_request_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
root: Event,
|
||||
@@ -791,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.
|
||||
@@ -849,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +52,19 @@ 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>>>,
|
||||
/// 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),
|
||||
/// Filter [`Self::visible_prs`] was last rebuilt for.
|
||||
///
|
||||
/// A filter change notifies even when the visible rows are unchanged,
|
||||
/// e.g. switching between two empty filters.
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: PullRequestFilter,
|
||||
/// Virtual list state of the pull requests list.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Rebuild the rows and re-render when the store's data changes.
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -117,7 +100,6 @@ impl PullRequestsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the visible rows, header counts and virtual-list sizes.
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
@@ -165,7 +147,6 @@ impl PullRequestsView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Open the detail panel of `pr_id` in the dock area.
|
||||
fn open_pull_request_detail(
|
||||
&mut self,
|
||||
pr_id: EventId,
|
||||
@@ -191,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;
|
||||
@@ -258,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()
|
||||
@@ -397,8 +374,6 @@ impl Render for PullRequestsView {
|
||||
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())
|
||||
|
||||
@@ -32,20 +32,15 @@ use signed_ui::{CountBadge, placeholder};
|
||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger};
|
||||
|
||||
/// 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 +48,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 +79,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 +96,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 +114,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 +142,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 +158,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 +183,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>,
|
||||
@@ -350,7 +325,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
@@ -387,7 +361,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,
|
||||
@@ -418,9 +391,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();
|
||||
|
||||
@@ -449,7 +420,6 @@ impl NewPullRequestView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Apply a picked checkout, filling the selectors and loading the compare.
|
||||
fn apply_checkout(
|
||||
&mut self,
|
||||
path: String,
|
||||
@@ -527,8 +497,6 @@ impl NewPullRequestView {
|
||||
self.reload_compare(window, cx);
|
||||
}
|
||||
|
||||
/// The base repository of the panel, its address and announced EUC.
|
||||
///
|
||||
/// 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);
|
||||
@@ -552,7 +520,6 @@ impl NewPullRequestView {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compare against an announced fork.
|
||||
fn choose_fork(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -607,9 +574,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.
|
||||
@@ -678,7 +642,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,
|
||||
@@ -783,7 +746,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;
|
||||
@@ -877,7 +839,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;
|
||||
@@ -959,7 +920,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;
|
||||
@@ -1117,7 +1077,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)),
|
||||
@@ -1128,7 +1087,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the compare-source menu.
|
||||
fn source_menu(
|
||||
&self,
|
||||
cx: &Context<Self>,
|
||||
@@ -1185,7 +1143,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title and description inputs.
|
||||
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.px_4()
|
||||
@@ -1196,7 +1153,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());
|
||||
@@ -1268,7 +1224,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()
|
||||
@@ -1354,7 +1309,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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,7 +18,6 @@ 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>) {
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
@@ -28,7 +27,6 @@ impl RepoDetailView {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Push the unpushed commits of the local checkout at `path`.
|
||||
pub(super) fn push_unpushed_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -71,7 +69,6 @@ impl RepoDetailView {
|
||||
.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>) {
|
||||
if self.store.read(cx).addr().is_none() {
|
||||
return;
|
||||
@@ -89,7 +86,6 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
if self.store.read(cx).addr().is_none() {
|
||||
return;
|
||||
@@ -124,7 +120,7 @@ impl RepoDetailView {
|
||||
open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx);
|
||||
}
|
||||
|
||||
/// Open the dialog guiding the user through publishing the local repository to NIP-34.
|
||||
/// Open the dialog that publishes 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.store.read(cx).path.clone() else {
|
||||
return;
|
||||
@@ -176,17 +172,8 @@ pub(crate) enum RepoItem {
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// Open the detail panel of `item` in `addr`'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.
|
||||
///
|
||||
/// `hint` is an announcement already in hand for `addr`, e.g. the inbox row the
|
||||
/// item was clicked from. The store resolves the repository from the local
|
||||
/// database on its own, so an entry point with only the address works too.
|
||||
///
|
||||
/// A patch opens nothing: patches are only consumed inside a pull request's
|
||||
/// detail panel, and have no panel of their own.
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
|
||||
@@ -67,9 +67,6 @@ impl RepoDetailView {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -155,7 +152,6 @@ impl RepoDetailView {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.read(cx);
|
||||
let warning = store.last_push_warning.clone()?;
|
||||
@@ -220,7 +216,6 @@ impl RepoDetailView {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
@@ -15,38 +15,28 @@ use signed_ui::{placeholder, tree_row};
|
||||
use super::RepoDetailView;
|
||||
use crate::views::repo::helpers::{code_language, is_markdown_path};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Preview state of a browsed file.
|
||||
pub(super) enum FileContent {
|
||||
/// Decodable text content.
|
||||
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.
|
||||
/// `None` means the repository README.
|
||||
pub(super) path: Option<SharedString>,
|
||||
pub(super) 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 {
|
||||
/// Source path, relative to the worktree root.
|
||||
pub(super) path: SharedString,
|
||||
@@ -55,8 +45,6 @@ pub(super) struct CodeView {
|
||||
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.
|
||||
@@ -68,7 +56,6 @@ fn source_hash(text: &str) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Spinner shown while a document is being loaded/parsed.
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
@@ -79,7 +66,6 @@ fn preview_spinner() -> AnyElement {
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// One row of the file tree with icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
@@ -96,7 +82,6 @@ impl RepoDetailView {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the file tree.
|
||||
pub(super) fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
@@ -117,7 +102,6 @@ impl RepoDetailView {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Right column, README, selected file preview or status text.
|
||||
pub(super) fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
@@ -230,7 +214,6 @@ 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(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
@@ -254,9 +237,6 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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,9 +259,6 @@ 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(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
@@ -312,7 +289,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();
|
||||
@@ -332,7 +308,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
@@ -455,7 +430,6 @@ impl RepoDetailView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Drop the cached preview, editor and commit state of `path`.
|
||||
pub(super) fn drop_preview_of(&mut self, path: &str) {
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||
self.preview_bytes -= text.len();
|
||||
|
||||
@@ -24,8 +24,6 @@ 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.store.read(cx).addr().is_none() {
|
||||
return self.render_local_header(cx);
|
||||
@@ -411,8 +409,6 @@ impl RepoDetailView {
|
||||
.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
|
||||
@@ -476,8 +472,6 @@ impl RepoDetailView {
|
||||
.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();
|
||||
@@ -658,7 +652,6 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -676,10 +669,6 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
|
||||
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()?;
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ 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>,
|
||||
}
|
||||
@@ -200,9 +199,6 @@ impl ShareTargets {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -232,7 +228,6 @@ impl ShareTargets {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -243,14 +238,9 @@ fn truncate_naddr_link(url: &str, tail: usize) -> 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 {
|
||||
@@ -259,11 +249,12 @@ pub(crate) enum DiffRow {
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
/// Line `line` of hunk `hunk` of the selected file's diff.
|
||||
Line { hunk: usize, line: usize },
|
||||
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() {
|
||||
@@ -281,7 +272,6 @@ pub(crate) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
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 {
|
||||
@@ -308,9 +298,6 @@ pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
@@ -358,7 +345,6 @@ pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
.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| {
|
||||
@@ -370,21 +356,16 @@ pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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,
|
||||
@@ -418,7 +399,6 @@ pub(crate) fn ref_selector_trigger(
|
||||
.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()
|
||||
@@ -428,7 +408,6 @@ pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
.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,
|
||||
@@ -511,7 +490,6 @@ pub(crate) fn sidebar_section(
|
||||
.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();
|
||||
@@ -559,8 +537,6 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
||||
.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>,
|
||||
@@ -621,11 +597,8 @@ pub(crate) fn comment_form(
|
||||
.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,
|
||||
@@ -745,45 +718,4 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -117,10 +115,6 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -167,9 +161,6 @@ impl RepoDetailView {
|
||||
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;
|
||||
@@ -209,7 +200,6 @@ impl RepoDetailView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Open a new panel showing the diff of `commit_id`.
|
||||
pub(super) fn open_commit_diff(
|
||||
&mut self,
|
||||
commit_id: &str,
|
||||
|
||||
@@ -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>),
|
||||
|
||||
@@ -15,7 +15,6 @@ 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`].
|
||||
@@ -32,8 +31,8 @@ struct RepoData {
|
||||
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.
|
||||
/// 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;
|
||||
@@ -110,7 +109,6 @@ impl RepoDetailView {
|
||||
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) => {
|
||||
@@ -247,7 +245,6 @@ impl RepoDetailView {
|
||||
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 {
|
||||
@@ -273,8 +270,6 @@ impl RepoDetailView {
|
||||
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();
|
||||
|
||||
@@ -338,7 +333,6 @@ impl RepoDetailView {
|
||||
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 store = self.store.clone();
|
||||
|
||||
@@ -404,9 +398,6 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -33,121 +33,84 @@ pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel};
|
||||
|
||||
use self::files::{CodeView, FileContent, MarkdownView};
|
||||
|
||||
/// What kind of ref the header selectors switch to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RefKind {
|
||||
/// A local branch `refs/heads/*`, HEAD stays attached.
|
||||
/// HEAD stays attached.
|
||||
Branch,
|
||||
/// A tag `refs/tags/*`, HEAD becomes detached.
|
||||
/// HEAD becomes detached.
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// Header actions dispatched by the dropdown menus of the header buttons.
|
||||
/// `pub(crate)` because the pull-request list panel shares this action set.
|
||||
/// It offers the New-PR and Send-patch actions in its own dropdown.
|
||||
/// Header actions dispatched by the header dropdown menus.
|
||||
///
|
||||
/// `pub(crate)` because the pull-request list panel shares this action set,
|
||||
/// offering the New-PR and Send-patch actions in its own dropdown.
|
||||
#[derive(Clone, Action, PartialEq, Eq)]
|
||||
#[action(namespace = repo, no_json)]
|
||||
pub(crate) enum RepoAction {
|
||||
/// Open the new issue dialog.
|
||||
NewIssue,
|
||||
/// Open the new pull request dialog.
|
||||
NewPR,
|
||||
/// Open the send patch panel.
|
||||
SendPatch,
|
||||
/// Open the about dialog.
|
||||
About,
|
||||
/// Re-push the repository to its grasp servers.
|
||||
Push,
|
||||
/// Delete the repository from nostr, owner only.
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Detail view of a repository, header, stats and metadata.
|
||||
///
|
||||
/// A file explorer with README preview, cloned from the announcement's `clone` URLs.
|
||||
pub struct RepoDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the detail view lives in.
|
||||
///
|
||||
/// New panels, commit diffs, are added there.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Per-repository store, holding the local path and the announcement,
|
||||
/// issues, PRs and statuses. The single identity of both modes.
|
||||
store: Entity<RepoStore>,
|
||||
/// The initial explorer load has been started.
|
||||
///
|
||||
/// A repository opened by address alone starts without an announcement; the
|
||||
/// store observer starts the load once the first one lands.
|
||||
repo_started: bool,
|
||||
/// File explorer state, the worktree of the local clone.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Root of the local clone, for reading files on demand.
|
||||
worktree: Option<PathBuf>,
|
||||
/// Sorted relative paths of the tree currently shown.
|
||||
///
|
||||
/// A background refresh that did not change the tree skips rebuilding it,
|
||||
/// see [`Self::catch_up_worktree`], so a fetch that learned nothing new
|
||||
/// does not flash the explorer.
|
||||
worktree_paths: Vec<String>,
|
||||
/// Markdown document currently in the preview pane, README or a file.
|
||||
md: Option<MarkdownView>,
|
||||
/// Code file currently in the preview pane.
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
/// Currently previewed file, a relative path, and its contents.
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
/// Paths of cached previews, oldest first.
|
||||
/// Feeds the eviction caps in [`Self::evict_previews`].
|
||||
/// Paths of cached previews, oldest first. Feeds the eviction caps in
|
||||
/// [`Self::evict_previews`].
|
||||
file_order: VecDeque<String>,
|
||||
/// Total text bytes held by [`Self::files`].
|
||||
preview_bytes: usize,
|
||||
/// Reads in flight, to avoid duplicate loads.
|
||||
loading_files: HashSet<String>,
|
||||
/// Latest commit touching a previewed file or the README, keyed by path.
|
||||
commits: HashMap<String, FileCommit>,
|
||||
/// Paths queued for the next batched commit query, see [`Self::load_commits`].
|
||||
pending_commits: Vec<String>,
|
||||
/// A batched commit query is in flight.
|
||||
loading_commits: bool,
|
||||
/// Active header tab, 0 = Files tree, 1 = Commits.
|
||||
/// 0 = Files tree, 1 = Commits.
|
||||
active_tab: usize,
|
||||
/// Commits reachable from HEAD, newest first.
|
||||
/// `None` until the walk finishes or fails.
|
||||
/// [`CommitList`] caps the list, `total` feeds the tab badge.
|
||||
/// Commits reachable from HEAD, newest first. `None` until the walk
|
||||
/// finishes or fails. `total` feeds the tab badge.
|
||||
all_commits: Option<CommitList>,
|
||||
/// Commit walk in flight.
|
||||
loading_all_commits: bool,
|
||||
/// Virtual list state of the Commits tab.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Commit HEAD currently points to, shown in the header button.
|
||||
head_commit: Option<FileCommit>,
|
||||
/// Branch selector in the header, local branches, searchable.
|
||||
branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Tag selector in the header, tags, searchable.
|
||||
tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Branch names currently in `branch_select`, for cheap no-op detection.
|
||||
ref_branches: Vec<SharedString>,
|
||||
/// Tag names currently in `tag_select`, for cheap no-op detection.
|
||||
ref_tags: Vec<SharedString>,
|
||||
/// A branch/tag switch is in flight, checkout plus explorer reload.
|
||||
switching_ref: bool,
|
||||
/// Bumped on every branch/tag switch.
|
||||
/// In-flight loads with an older generation are discarded when they complete.
|
||||
ref_generation: u64,
|
||||
/// Subscriptions keeping the selectors' confirm events alive.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
|
||||
banner_dismissed: HashSet<(PathBuf, String)>,
|
||||
/// The announced HEAD the ready statuses were last requested with.
|
||||
/// Whether they were requested at all.
|
||||
/// Re-requested only when the HEAD, the base default, changes.
|
||||
/// e.g. when the store's first refresh lands.
|
||||
/// Whether the ready statuses were requested at all.
|
||||
ready_requested: bool,
|
||||
/// The announced HEAD they were last requested with. Re-requested only when
|
||||
/// the HEAD, the base default, changes, e.g. when the store's first refresh
|
||||
/// lands.
|
||||
ready_head: Option<String>,
|
||||
/// The global checkouts store's ready-to-contribute statuses of this
|
||||
/// repository, last seen when they drove a render.
|
||||
@@ -155,14 +118,10 @@ pub struct RepoDetailView {
|
||||
/// The store notifies on any recompute pass; the observer re-renders this
|
||||
/// panel only when these slices changed.
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
/// The global checkouts store's ready-to-push statuses of this repository,
|
||||
/// last seen when they drove a render.
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Open a repository by address.
|
||||
///
|
||||
/// `hint` is an announcement already in hand. It seeds the store's relays
|
||||
/// and the explorer's clone URLs; without it the panel waits for the store
|
||||
/// to load the announcement from the local database.
|
||||
@@ -177,7 +136,6 @@ impl RepoDetailView {
|
||||
Self::new_common(dock_area, store, window, cx)
|
||||
}
|
||||
|
||||
/// Open a local repository discovered by the scan.
|
||||
pub fn new_local(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
local_path: PathBuf,
|
||||
@@ -188,9 +146,6 @@ impl RepoDetailView {
|
||||
Self::new_common(dock_area, store, window, cx)
|
||||
}
|
||||
|
||||
/// Shared construction.
|
||||
///
|
||||
/// File explorer state, ref selectors and the deferred repository load.
|
||||
fn new_common(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
@@ -306,8 +261,8 @@ impl RepoDetailView {
|
||||
self.store.read(cx).announcement.as_ref()
|
||||
}
|
||||
|
||||
/// Display name, the announcement's name or ID for announced repositories.
|
||||
/// The directory name for local ones.
|
||||
/// The announcement's name or ID for announced repositories, the directory
|
||||
/// name for local ones.
|
||||
fn display_name(&self, cx: &App) -> SharedString {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ use super::{RefKind, RepoDetailView};
|
||||
use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items};
|
||||
|
||||
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,
|
||||
@@ -81,7 +79,6 @@ impl RepoDetailView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Restore a selector to `previous`, or clear it after a failed switch.
|
||||
fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
@@ -95,10 +92,6 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -26,8 +26,6 @@ impl RepoDetailView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Observe the repository's store and re-render on its refreshes.
|
||||
/// Start the explorer once the store has an announcement.
|
||||
pub(super) fn attach_store(
|
||||
&mut self,
|
||||
store: &Entity<RepoStore>,
|
||||
@@ -82,7 +80,6 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// The ready-to-contribute and ready-to-push statuses of this repository.
|
||||
pub(super) fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return false;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,7 +246,6 @@ fn create_repository(
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Open the newly created repository in the dock's center.
|
||||
fn open_repo(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
announcement: Announcement,
|
||||
|
||||
@@ -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.))
|
||||
|
||||
@@ -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,12 +238,10 @@ 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,
|
||||
@@ -269,8 +257,6 @@ impl SidebarPanel {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 =
|
||||
@@ -334,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 {
|
||||
@@ -374,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],
|
||||
@@ -403,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())
|
||||
@@ -433,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()
|
||||
@@ -455,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,
|
||||
@@ -490,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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user