chore: clean up codebase #19
Generated
+1
-52
@@ -3561,7 +3561,6 @@ dependencies = [
|
||||
"gpui_macros",
|
||||
"gpui_shared_string",
|
||||
"gpui_util",
|
||||
"hdrhistogram",
|
||||
"heapless 0.9.3",
|
||||
"http_client",
|
||||
"image",
|
||||
@@ -3745,20 +3744,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui-fps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"instant",
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"sysinfo 0.37.2",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui_apple"
|
||||
version = "0.1.0"
|
||||
@@ -4096,16 +4081,6 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hdrhistogram"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
@@ -6035,16 +6010,6 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -8003,7 +7968,6 @@ version = "0.1.0-alpha"
|
||||
dependencies = [
|
||||
"nostr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8526,20 +8490,6 @@ dependencies = [
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.37.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
@@ -10907,7 +10857,6 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui-base",
|
||||
"gpui-component",
|
||||
"gpui-fps",
|
||||
"log",
|
||||
"nostr",
|
||||
"paths",
|
||||
@@ -11333,7 +11282,7 @@ dependencies = [
|
||||
"rand 0.8.8",
|
||||
"screencapturekit",
|
||||
"screencapturekit-sys",
|
||||
"sysinfo 0.31.4",
|
||||
"sysinfo",
|
||||
"tao-core-video-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-capture",
|
||||
|
||||
@@ -18,7 +18,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
# GPUI Kit
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+238
-131
@@ -1,7 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, bail};
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
@@ -22,9 +21,6 @@ use crate::git_store::GitStore;
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
use crate::repos::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Maximum size of one patch event.
|
||||
///
|
||||
/// NIP-34 suggests patches when each event is under 60kb.
|
||||
@@ -35,8 +31,18 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||
/// Always derived from the local database.
|
||||
pub struct RepoStore {
|
||||
addr: RepoAddr,
|
||||
/// NIP-34 address. `None` while the repository is local-only.
|
||||
addr: Option<RepoAddr>,
|
||||
/// Latest announcement. Seeded from the open-time hint, replaced by the
|
||||
/// database's latest on the first pass. `None` while local-only.
|
||||
pub announcement: Option<Announcement>,
|
||||
/// Local working copy. The scan path for a local repository, kept when it is
|
||||
/// later announced so the panel keeps its worktree.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The first local pass has been applied.
|
||||
///
|
||||
/// Views distinguish "no data yet" from a genuinely empty repository with it.
|
||||
pub loaded: bool,
|
||||
/// Branch pointed to by `HEAD` in the latest state announcement.
|
||||
pub head: Option<String>,
|
||||
pub issues: Vec<Event>,
|
||||
@@ -54,7 +60,6 @@ pub struct RepoStore {
|
||||
///
|
||||
/// Views key their derived-data caches to it instead of recomputing on every render.
|
||||
version: u64,
|
||||
/// Error of the last action initiated from this store, if any.
|
||||
pub last_error: Option<String>,
|
||||
/// Non-fatal warning of the last action, if any.
|
||||
///
|
||||
@@ -80,54 +85,21 @@ pub struct RepoStore {
|
||||
///
|
||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||
root_fetches: HashSet<EventId>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
_subscription: Subscription,
|
||||
/// Backend subscription of an announced repository. `None` while local-only.
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl RepoStore {
|
||||
pub fn new(addr: RepoAddr, announced_relays: Vec<RelayUrl>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||
// Deletions may target any event of this repository.
|
||||
let deletion =
|
||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||
let coordinate = update.coordinate.as_ref() == Some(&this.addr);
|
||||
let author = update.author == this.addr.public_key;
|
||||
let kind = update.kind == Kind::GitRepoAnnouncement;
|
||||
// NIP-22 comments carry no `a` tag.
|
||||
// Coordinate matching fails for them.
|
||||
// Any comment may reference this repository's roots.
|
||||
let comment = update.kind == Kind::Comment;
|
||||
// Status events may omit their `a` tag, NIP-34.
|
||||
// Any status event may reference a root of this repository.
|
||||
let status = RepoStatus::from_kind(update.kind).is_some();
|
||||
|
||||
deletion || coordinate || (author && kind) || comment || status
|
||||
}),
|
||||
BackendEvent::Published(event) => {
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author = event.pubkey == this.addr.public_key;
|
||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
|
||||
// Locally published deletions may target any event of this repository.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
coordinate || (kind && author) || deletion
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if relevant {
|
||||
this.refresh(cx);
|
||||
}
|
||||
});
|
||||
|
||||
pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
let subscription = Self::subscribe_backend(cx);
|
||||
|
||||
let announced_relays = hint
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.relays.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let result = weak.update(cx, |this, cx| {
|
||||
this.subscribe_remote(cx);
|
||||
@@ -141,8 +113,10 @@ impl RepoStore {
|
||||
});
|
||||
|
||||
Self {
|
||||
addr,
|
||||
announcement: None,
|
||||
addr: Some(addr),
|
||||
announcement: hint,
|
||||
path: None,
|
||||
loaded: false,
|
||||
head: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
@@ -160,13 +134,101 @@ impl RepoStore {
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the repository's address.
|
||||
pub fn addr(&self) -> &RepoAddr {
|
||||
&self.addr
|
||||
/// Local repository discovered by the scan, not announced to NIP-34 yet.
|
||||
pub fn new_local(path: PathBuf) -> Self {
|
||||
Self {
|
||||
addr: None,
|
||||
announcement: None,
|
||||
path: Some(path),
|
||||
loaded: true,
|
||||
head: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
pull_requests: Vec::new(),
|
||||
comments: Vec::new(),
|
||||
status_by_root: HashMap::new(),
|
||||
open_issue_count: 0,
|
||||
open_pr_count: 0,
|
||||
version: 0,
|
||||
last_error: None,
|
||||
last_warning: None,
|
||||
last_push_warning: None,
|
||||
pushing: false,
|
||||
cloning: false,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch a local repository to its NIP-34 mode, keeping its path.
|
||||
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
|
||||
self.addr = Some(announcement.addr());
|
||||
self.announcement = Some(announcement.clone());
|
||||
self.loaded = false;
|
||||
|
||||
if self._subscription.is_none() {
|
||||
self._subscription = Some(Self::subscribe_backend(cx));
|
||||
}
|
||||
|
||||
self.subscribe_remote(cx);
|
||||
self.connect_announced_relays(&announcement.relays, cx);
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
fn subscribe_backend(cx: &mut Context<Self>) -> Subscription {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let Some(addr) = this.addr.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||
// Deletions may target any event of this repository.
|
||||
let deletion =
|
||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||
let coordinate = update.coordinate.as_ref() == Some(addr);
|
||||
let author = update.author == addr.public_key;
|
||||
let kind = update.kind == Kind::GitRepoAnnouncement;
|
||||
// NIP-22 comments carry no `a` tag.
|
||||
// Coordinate matching fails for them.
|
||||
// Any comment may reference this repository's roots.
|
||||
let comment = update.kind == Kind::Comment;
|
||||
// Status events may omit their `a` tag, NIP-34.
|
||||
// Any status event may reference a root of this repository.
|
||||
let status = RepoStatus::from_kind(update.kind).is_some();
|
||||
|
||||
deletion || coordinate || (author && kind) || comment || status
|
||||
}),
|
||||
BackendEvent::Published(event) => {
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author = event.pubkey == addr.public_key;
|
||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
|
||||
// Locally published deletions may target any event of this repository.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
coordinate || (kind && author) || deletion
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if relevant {
|
||||
this.refresh(cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> Option<&RepoAddr> {
|
||||
self.addr.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the repository's name, or `Unknown` when not known.
|
||||
@@ -198,6 +260,10 @@ impl RepoStore {
|
||||
|
||||
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
|
||||
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new: Vec<RelayUrl> = relays
|
||||
.iter()
|
||||
.filter(|url| !self.repo_relays.contains(*url))
|
||||
@@ -210,17 +276,18 @@ impl RepoStore {
|
||||
self.repo_relays.extend(new.iter().cloned());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let addr = self.addr.clone();
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch this repository's events from the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let addr = self.addr.clone();
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.subscribe_bootstrap(Self::repo_filters(&addr), cx);
|
||||
@@ -228,25 +295,30 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Re-query the local database and update all fields.
|
||||
///
|
||||
/// Runs immediately. The backend pump already batches the relay events that
|
||||
/// trigger a refresh, so no per-store debounce is needed.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.addr.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
})
|
||||
.detach();
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let (announcements, states, activity, deletion_events) = async {
|
||||
@@ -397,7 +469,37 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.announcement = announcement;
|
||||
// Compare before moving the freshly queried data in, so a pass
|
||||
// that found nothing new does not notify observers. The store
|
||||
// is polled in bursts while a sync is in flight; notifying on
|
||||
// every identical pass would re-render the repository panel
|
||||
// several times for no visible change.
|
||||
//
|
||||
// The first pass is the exception: it must notify even when it
|
||||
// found nothing, so views can leave their loading state and show
|
||||
// the empty result.
|
||||
//
|
||||
// Keep the open-time hint until that first pass has confirmed what
|
||||
// the database holds; afterwards the database is the truth,
|
||||
// including a deletion.
|
||||
let keep_hint = announcement.is_none() && !this.loaded;
|
||||
|
||||
let first_pass = !this.loaded;
|
||||
let head_changed = state
|
||||
.as_ref()
|
||||
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
||||
let changed = first_pass
|
||||
|| (!keep_hint && this.announcement != announcement)
|
||||
|| head_changed
|
||||
|| this.issues != issues
|
||||
|| this.patches != patches
|
||||
|| this.pull_requests != pull_requests
|
||||
|| this.comments != comments
|
||||
|| this.status_by_root != status_by_root;
|
||||
|
||||
if !keep_hint {
|
||||
this.announcement = announcement;
|
||||
}
|
||||
|
||||
// The announcement may list relays for this repository's activity.
|
||||
// Connect to any we have not fetched from yet.
|
||||
@@ -419,6 +521,7 @@ impl RepoStore {
|
||||
this.status_by_root = status_by_root;
|
||||
this.open_issue_count = open_issue_count;
|
||||
this.open_pr_count = open_pr_count;
|
||||
this.loaded = true;
|
||||
this.version = this.version.wrapping_add(1);
|
||||
|
||||
// Comments and statuses without an `a` tag.
|
||||
@@ -454,7 +557,9 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
@@ -475,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
|
||||
}
|
||||
@@ -502,13 +605,19 @@ impl RepoStore {
|
||||
/// The author is the public key of the repository address.
|
||||
/// Only the author may manage pull requests, close, reopen or merge.
|
||||
pub fn is_author(&self, user: &PublicKey) -> bool {
|
||||
&self.addr.public_key == user
|
||||
self.addr
|
||||
.as_ref()
|
||||
.is_some_and(|addr| &addr.public_key == user)
|
||||
}
|
||||
|
||||
/// Open an issue on this repository.
|
||||
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
self.not_announced(cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let builder = GitIssue {
|
||||
repository: self.addr.clone(),
|
||||
repository: addr,
|
||||
content,
|
||||
subject,
|
||||
labels: Vec::new(),
|
||||
@@ -518,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()
|
||||
@@ -540,6 +648,11 @@ impl RepoStore {
|
||||
content: String,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
self.not_announced(cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let relay_hint = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -547,12 +660,11 @@ impl RepoStore {
|
||||
.cloned();
|
||||
|
||||
self.publish(
|
||||
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
|
||||
comment_builder(root, parent, relay_hint.as_ref(), &addr, content),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a pull request on this repository.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_pull_request(
|
||||
&mut self,
|
||||
@@ -568,6 +680,11 @@ impl RepoStore {
|
||||
self.last_error = None;
|
||||
self.last_warning = None;
|
||||
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
self.not_announced(cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let series: Vec<String> = signed_git::split_patch_series(&patch)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
@@ -611,8 +728,7 @@ impl RepoStore {
|
||||
// The author's npub names their GRASP-06 namespace, `/prs/...`.
|
||||
let author_npub = user.to_bech32().unwrap();
|
||||
|
||||
let addr = self.addr.clone();
|
||||
let owner = self.addr.public_key;
|
||||
let owner = addr.public_key;
|
||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
let repo_id = addr.identifier.clone();
|
||||
let base_npub = owner.to_bech32().unwrap();
|
||||
@@ -728,7 +844,7 @@ impl RepoStore {
|
||||
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||
|
||||
let builder = GitPullRequest {
|
||||
repository: this.addr.clone(),
|
||||
repository: addr.clone(),
|
||||
content: description,
|
||||
subject,
|
||||
labels: Vec::new(),
|
||||
@@ -947,8 +1063,11 @@ impl RepoStore {
|
||||
.map(|p| p.id)
|
||||
});
|
||||
|
||||
let addr = self.addr.clone();
|
||||
let owner = self.addr.public_key;
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
self.not_announced(cx);
|
||||
return;
|
||||
};
|
||||
let owner = addr.public_key;
|
||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
let root = root.clone();
|
||||
let clone: Vec<Url> = self
|
||||
@@ -976,9 +1095,9 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
let builder = this.update(cx, |this, _cx| {
|
||||
let builder = this.update(cx, |_this, _cx| {
|
||||
let builder = GitPullRequestUpdate {
|
||||
repository: this.addr.clone(),
|
||||
repository: addr.clone(),
|
||||
pull_request_event: root.id,
|
||||
pull_request_author: root.pubkey,
|
||||
current_commit,
|
||||
@@ -1035,6 +1154,11 @@ impl RepoStore {
|
||||
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
self.not_announced(cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -1060,19 +1184,23 @@ impl RepoStore {
|
||||
|
||||
let builder = EventBuilder::new(status.kind(), "").tags([
|
||||
root_ref,
|
||||
Tag::public_key(self.addr.public_key),
|
||||
Tag::public_key(addr.public_key),
|
||||
Tag::public_key(root.pubkey),
|
||||
Tag::coordinate(self.addr.clone(), None),
|
||||
Tag::coordinate(addr, None),
|
||||
]);
|
||||
|
||||
self.publish(builder, cx);
|
||||
}
|
||||
|
||||
/// Merge a pull request.
|
||||
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
self.last_warning = None;
|
||||
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
self.not_announced(cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let is_author = Backend::global(cx)
|
||||
.read(cx)
|
||||
.current_user()
|
||||
@@ -1083,7 +1211,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
let clone_urls: Vec<Url> = self
|
||||
.announcement
|
||||
@@ -1152,17 +1279,17 @@ impl RepoStore {
|
||||
/// The latest announcement of this repository,
|
||||
/// for operations that need its clone URLs and relays.
|
||||
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
|
||||
let addr = self.addr.as_ref()?;
|
||||
self.announcement.clone().or_else(|| {
|
||||
RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == self.addr)
|
||||
.find(|announcement| announcement.addr() == *addr)
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-push the repository's refs to its announced grasp servers, republish.
|
||||
pub fn push_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
if self.pushing {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
@@ -1207,7 +1334,6 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the unpushed commits of the local checkout at `path`.
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -1219,6 +1345,10 @@ impl RepoStore {
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return self.action_error("This repository is not published to Nostr yet", cx);
|
||||
};
|
||||
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
@@ -1226,7 +1356,6 @@ impl RepoStore {
|
||||
// The state event's `HEAD` stays the announced default branch.
|
||||
// The checkout may be on a side branch.
|
||||
let head = self.head.clone();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
self.pushing = true;
|
||||
self.last_error = None;
|
||||
@@ -1274,7 +1403,9 @@ impl RepoStore {
|
||||
/// Only the repository owner may delete it. The lists update when the
|
||||
/// deletion events arrive.
|
||||
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
let addr = self.addr.clone();
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return self.action_error("This repository is not published to Nostr yet", cx);
|
||||
};
|
||||
self.last_error = None;
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
@@ -1304,12 +1435,16 @@ impl RepoStore {
|
||||
"A clone of this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return self.action_error("This repository is not published to Nostr yet", cx);
|
||||
};
|
||||
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
|
||||
let clone_urls = announcement.clone.clone();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
self.cloning = true;
|
||||
self.last_error = None;
|
||||
@@ -1328,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);
|
||||
@@ -1346,7 +1480,11 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fail an operation whose announcement is not loaded yet.
|
||||
fn not_announced(&mut self, cx: &mut Context<Self>) {
|
||||
self.last_error = Some("This repository is not published to Nostr yet".into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn action_error(
|
||||
&mut self,
|
||||
message: impl Into<String>,
|
||||
@@ -1369,11 +1507,15 @@ impl RepoStore {
|
||||
euc: Option<&str>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(addr) = self.addr.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut tags = vec![
|
||||
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
|
||||
Tag::public_key(self.addr.public_key),
|
||||
Tag::public_key(addr.public_key),
|
||||
Tag::public_key(root.pubkey),
|
||||
Tag::coordinate(self.addr.clone(), None),
|
||||
Tag::coordinate(addr, None),
|
||||
];
|
||||
|
||||
if let Some(euc) = euc
|
||||
@@ -1466,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)
|
||||
@@ -1474,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],
|
||||
@@ -1641,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();
|
||||
@@ -1683,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;
|
||||
@@ -117,11 +111,6 @@ impl LocalReposStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
///
|
||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||
|
||||
@@ -154,19 +143,16 @@ pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository.
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues, pull requests and commits per repository.
|
||||
///
|
||||
/// 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()
|
||||
}
|
||||
@@ -175,9 +161,9 @@ 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();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
@@ -217,17 +203,12 @@ impl RepoListStore {
|
||||
}
|
||||
});
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
let result = weak.update(cx, |this, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.subscribe_remote(cx);
|
||||
// Query the local database right away.
|
||||
// The list never waits for the relay syncs started above to finish.
|
||||
this.refresh_initial(cx);
|
||||
});
|
||||
if let Err(error) = result {
|
||||
log::warn!("repo list store dropped before bootstrap could run: {error}");
|
||||
}
|
||||
this.refresh(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -248,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);
|
||||
|
||||
@@ -259,33 +239,18 @@ impl RepoListStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load.
|
||||
///
|
||||
/// Query the local database immediately, no debounce.
|
||||
/// Stored announcements appear as soon as the app opens.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
///
|
||||
/// Runs immediately. The backend pump already batches the relay events that
|
||||
/// trigger a refresh, so no per-store debounce is needed.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
})
|
||||
.detach();
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
|
||||
@@ -162,24 +162,3 @@ fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::div;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dropdown_button_builder_state() {
|
||||
let button = DropdownButton::new("issues")
|
||||
.action(div())
|
||||
.anchor(Anchor::BottomLeft)
|
||||
.dropdown_menu(|menu, _, _| menu);
|
||||
|
||||
assert!(button.action.is_some());
|
||||
// The caret is `None` until render, which falls back to the default.
|
||||
assert!(button.caret.is_none());
|
||||
assert!(button.menu.is_some());
|
||||
assert_eq!(button.anchor, Anchor::BottomLeft);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
mod placeholder;
|
||||
mod ref_selector;
|
||||
mod segment_button;
|
||||
mod setting;
|
||||
mod status_badge;
|
||||
@@ -17,6 +18,7 @@ pub use dropdown_button::DropdownButton;
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
pub use ref_selector::ref_selector_trigger;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
pub use setting::{SelectOption, setting_block, setting_row};
|
||||
pub use status_badge::status_badge;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
|
||||
/// The kind icon, the selection or placeholder, and the caret. `Combobox`
|
||||
/// replaces its default trigger entirely, the only way to show an icon inside it.
|
||||
pub fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ utils = { path = "../utils" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-fps.workspace = true
|
||||
gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
|
||||
+180
-30
@@ -13,34 +13,25 @@ use gpui_component::resizable::{resizable_panel, v_resizable};
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
|
||||
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
use crate::views::tree::{build_tree_items, tree_items};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
/// Tree and per-file diff body, shared by the commit diff and compare views.
|
||||
pub struct DiffPane {
|
||||
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
||||
diff: Option<CommitDiff>,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
rows: Vec<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the diff rows.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
}
|
||||
|
||||
@@ -56,12 +47,10 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// The loaded diff, for stats and badges in the host's header.
|
||||
pub fn diff(&self) -> Option<&CommitDiff> {
|
||||
self.diff.as_ref()
|
||||
}
|
||||
|
||||
/// Replace the diff and rebuild the tree and the selected file's rows.
|
||||
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
@@ -86,9 +75,6 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the diff, e.g. when the compared branches changed.
|
||||
///
|
||||
/// Clears the tree, the selection and the diff rows.
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
self.diff = None;
|
||||
self.selected_file = None;
|
||||
@@ -99,14 +85,12 @@ impl DiffPane {
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the diff of the file at `path`, selected in the tree.
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Rebuild the virtual list state for `path` and scroll back to the top.
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
@@ -119,7 +103,6 @@ impl DiffPane {
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
/// One row of the changed-files tree, icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
@@ -136,7 +119,6 @@ impl DiffPane {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the changed-files tree.
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
@@ -166,7 +148,6 @@ impl DiffPane {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right column, header of the selected file plus its diff.
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("No changes", cx);
|
||||
@@ -184,7 +165,6 @@ impl DiffPane {
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
/// The diff of one file, with a header showing status and stats.
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
DiffStatus::Added => "A",
|
||||
@@ -311,19 +291,14 @@ impl Render for DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail panel showing the diff of one commit.
|
||||
pub struct CommitDiffView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Local clone the commit lives in.
|
||||
worktree: PathBuf,
|
||||
/// Display name of the repository the commit belongs to.
|
||||
repo_name: SharedString,
|
||||
/// The commit being shown in the header and tab title.
|
||||
commit: FileCommit,
|
||||
/// The diff is being computed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||
pane: Entity<DiffPane>,
|
||||
}
|
||||
|
||||
@@ -359,7 +334,6 @@ impl CommitDiffView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the commit diff and the full commit metadata.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -407,7 +381,6 @@ impl CommitDiffView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Header with the commit id, summary, author/time and overall change stats.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
@@ -535,3 +508,180 @@ impl Render for CommitDiffView {
|
||||
.child(resizable_panel().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
const GUTTER_WIDTH: f32 = 44.;
|
||||
const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
Line {
|
||||
hunk: usize,
|
||||
line: usize,
|
||||
},
|
||||
}
|
||||
|
||||
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(crate) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::UserAvatar;
|
||||
use utils::relative_time;
|
||||
|
||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -21,20 +21,13 @@ use utils::relative_time;
|
||||
|
||||
use super::{RepoItem, open_repo_item};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Extra list rows measured above and below the visible area.
|
||||
const LIST_OVERDRAW: Pixels = px(400.);
|
||||
|
||||
/// Maximum number of sub-activity lines shown under a thread row.
|
||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||
|
||||
/// A repository's slice of the inbox: the threads that belong to it.
|
||||
struct InboxSection {
|
||||
/// Repository the section groups, `None` for items without one.
|
||||
/// `None` for items without a repository.
|
||||
address: Option<RepoAddr>,
|
||||
/// Number of threads with an unread event.
|
||||
unread: usize,
|
||||
/// Indices into the threads, newest activity first.
|
||||
entries: Vec<usize>,
|
||||
@@ -54,15 +47,11 @@ pub struct InboxView {
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// One row per thread, merging notifications and own activity, newest first.
|
||||
threads: Arc<Vec<InboxItem>>,
|
||||
/// The threads grouped by repository, newest first.
|
||||
sections: Arc<Vec<InboxSection>>,
|
||||
/// The flattened repository headers and rows of the list.
|
||||
rows: Arc<Vec<InboxRow>>,
|
||||
/// Number of non-archived threads with an unread event.
|
||||
unread_count: usize,
|
||||
/// Copy of the global read state the current lists were derived with.
|
||||
state: InboxReadState,
|
||||
/// Set once the global state has been read for the current user.
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
@@ -118,7 +107,6 @@ impl InboxView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
return;
|
||||
@@ -136,7 +124,6 @@ impl InboxView {
|
||||
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
|
||||
}
|
||||
|
||||
/// Re-derive from the global state when it is loaded or changes.
|
||||
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
@@ -170,7 +157,6 @@ impl InboxView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a backend event that can change the derived sections.
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
@@ -192,7 +178,6 @@ impl InboxView {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot initial load, no debounce.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
@@ -202,7 +187,6 @@ impl InboxView {
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.state_loaded {
|
||||
return;
|
||||
@@ -218,7 +202,6 @@ impl InboxView {
|
||||
}));
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
@@ -264,7 +247,6 @@ impl InboxView {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from the current state.
|
||||
fn regroup(&mut self, cx: &mut Context<Self>) {
|
||||
let mut items = (*self.threads).clone();
|
||||
|
||||
@@ -277,7 +259,6 @@ impl InboxView {
|
||||
self.rebuild(cx);
|
||||
}
|
||||
|
||||
/// Regroup the current threads by repository and flatten them into rows.
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
@@ -308,7 +289,6 @@ impl InboxView {
|
||||
self.rows = Arc::new(rows);
|
||||
}
|
||||
|
||||
/// Group the threads into one section per repository.
|
||||
fn group_sections(&self) -> Vec<InboxSection> {
|
||||
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
|
||||
|
||||
@@ -349,7 +329,6 @@ impl InboxView {
|
||||
sections
|
||||
}
|
||||
|
||||
/// Flatten the sections into the list of repository headers and their rows.
|
||||
fn flatten_rows(&self, sections: &[InboxSection]) -> Vec<InboxRow> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
@@ -369,7 +348,6 @@ impl InboxView {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Forget everything derived for the current user.
|
||||
fn clear(&mut self) {
|
||||
self.threads = Arc::new(Vec::new());
|
||||
self.sections = Arc::new(Vec::new());
|
||||
@@ -393,16 +371,6 @@ impl InboxView {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(announcement) = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == address)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let item = match kind {
|
||||
Some(Kind::GitIssue) => RepoItem::Issue(root),
|
||||
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
|
||||
@@ -410,7 +378,7 @@ impl InboxView {
|
||||
_ => return,
|
||||
};
|
||||
|
||||
open_repo_item(&self.dock_area, &announcement, item, window, cx);
|
||||
open_repo_item(&self.dock_area, &address, None, item, window, cx);
|
||||
}
|
||||
|
||||
fn render_entry(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||
@@ -455,7 +423,6 @@ impl InboxView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name of the repository at `addr`, from the announcement store.
|
||||
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let addr = addr?;
|
||||
@@ -467,7 +434,6 @@ fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
.map(|announcement| announcement.name().map(SharedString::from))
|
||||
}
|
||||
|
||||
/// Header of a repository section.
|
||||
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
let name =
|
||||
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
|
||||
@@ -491,7 +457,6 @@ fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Placeholder under a repository header that has nothing to show.
|
||||
fn empty_section_row(cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
@@ -610,7 +575,6 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Phrase describing an activity event, read as `[name] [phrase]`.
|
||||
fn activity_phrase(kind: Kind) -> &'static str {
|
||||
if kind == COVER_NOTE_KIND {
|
||||
return "added a note";
|
||||
@@ -630,7 +594,6 @@ fn activity_phrase(kind: Kind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered muted icon and message filling its container.
|
||||
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
|
||||
+14
-8
@@ -1,8 +1,8 @@
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
|
||||
relative,
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription,
|
||||
Window, div, relative,
|
||||
};
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
@@ -13,16 +13,14 @@ use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
/// Repo store holding the issues and their statuses.
|
||||
focus_handle: FocusHandle,
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssueDetailView {
|
||||
@@ -35,11 +33,14 @@ impl IssueDetailView {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +82,12 @@ impl Render for IssueDetailView {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
|
||||
return placeholder("Issue not found", cx);
|
||||
// The store has not applied its first pass yet, the issue may still arrive.
|
||||
return if store.loaded {
|
||||
placeholder("Issue not found", cx)
|
||||
} else {
|
||||
placeholder("Loading issue...", cx)
|
||||
};
|
||||
};
|
||||
|
||||
let (title, author, picture, status, age, issue_id, content) = {
|
||||
+69
-63
@@ -4,8 +4,8 @@ use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
@@ -21,24 +21,20 @@ use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::issue_detail::IssueDetailView;
|
||||
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,
|
||||
@@ -50,37 +46,37 @@ impl IssueFilter {
|
||||
|
||||
pub struct IssuesView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the issue detail panel is opened in.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the issues and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Filter selected in the header filter buttons.
|
||||
filter: IssueFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// The filtered issue count [`Self::item_sizes`] was built for.
|
||||
issue_len: usize,
|
||||
/// Indices into the store's `issues` matching [`Self::filter`].
|
||||
visible_issues: Vec<usize>,
|
||||
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
|
||||
counts: (usize, usize, usize),
|
||||
/// Store version and filter the cached rows/counts were built from.
|
||||
cache_key: Option<(u64, IssueFilter)>,
|
||||
/// Virtual list state of the issues list.
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: IssueFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssuesView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -88,14 +84,60 @@ impl IssuesView {
|
||||
repo_name,
|
||||
filter: IssueFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
issue_len: 0,
|
||||
visible_issues: Vec::new(),
|
||||
counts: (0, 0, 0),
|
||||
cache_key: None,
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_issues, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize);
|
||||
|
||||
let visible_issues: Vec<usize> = store
|
||||
.issues
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, issue)| {
|
||||
let status = store.status_of(issue);
|
||||
counts.0 += 1;
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft | RepoStatus::Applied => {}
|
||||
}
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible_issues, counts)
|
||||
};
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
let visible_issues_changed = self.visible_issues != visible_issues;
|
||||
let counts_changed = self.counts != counts;
|
||||
|
||||
if !filter_changed && !visible_issues_changed && !counts_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.item_sizes = Rc::new(vec![
|
||||
size(px(0.), px(ISSUE_ROW_HEIGHT));
|
||||
visible_issues.len()
|
||||
]);
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.visible_issues = visible_issues;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Open the detail panel of `issue_id` in the dock area.
|
||||
fn open_issue_detail(
|
||||
&mut self,
|
||||
@@ -176,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()
|
||||
@@ -197,7 +238,7 @@ impl IssuesView {
|
||||
.selected(self.filter == IssueFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::All;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -207,7 +248,7 @@ impl IssuesView {
|
||||
.selected(self.filter == IssueFilter::Open)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Open;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -217,7 +258,7 @@ impl IssuesView {
|
||||
.selected(self.filter == IssueFilter::Closed)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Closed;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -234,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..."));
|
||||
@@ -322,41 +362,7 @@ impl Focusable for IssuesView {
|
||||
impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||
let version = self.store.read(cx).version();
|
||||
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize);
|
||||
self.visible_issues = store
|
||||
.issues
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, issue)| {
|
||||
let status = store.status_of(issue);
|
||||
counts.0 += 1;
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft | RepoStatus::Applied => {}
|
||||
}
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
self.counts = counts;
|
||||
self.cache_key = Some((version, filter));
|
||||
}
|
||||
|
||||
let count = self.visible_issues.len();
|
||||
|
||||
// The virtual list's item count comes from `item_sizes`.
|
||||
// Rebuild it whenever the filtered issue count changes.
|
||||
if count != self.issue_len {
|
||||
self.issue_len = count;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
|
||||
}
|
||||
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
mod commit_diff;
|
||||
mod dialog_state;
|
||||
pub(crate) mod discussion;
|
||||
mod inbox;
|
||||
mod repo_detail;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
mod send_patch;
|
||||
pub(crate) mod sidebar;
|
||||
pub(crate) mod tree;
|
||||
|
||||
pub use inbox::InboxView;
|
||||
pub use repo_detail::RepoDetailView;
|
||||
pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo::RepoDetailView;
|
||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
|
||||
+215
-172
@@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, relative, size,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
@@ -20,9 +20,9 @@ use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::{Event, EventId, Kind};
|
||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
||||
use signed_core::{
|
||||
activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
||||
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
||||
merge_base_of, pull_request_patch,
|
||||
};
|
||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||
@@ -30,45 +30,57 @@ use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
|
||||
/// Height of one commit row in the commits tab's virtual list.
|
||||
const ROW_HEIGHT: f32 = 37.;
|
||||
|
||||
/// Shown once the store's first pass is applied and the root PR is still absent.
|
||||
const NOT_FOUND: &str = "Pull request not found";
|
||||
|
||||
/// A store refresh re-binds the panel, and reloads only when these change.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct PrBinding {
|
||||
description: String,
|
||||
patch: String,
|
||||
tip: Option<String>,
|
||||
base: Option<String>,
|
||||
clone_urls: Vec<Url>,
|
||||
addr: RepoAddr,
|
||||
has_patch_link: bool,
|
||||
}
|
||||
|
||||
/// Detail panel of a single pull request.
|
||||
pub struct PullRequestDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area where new panels, e.g. commit diffs, are added.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the PR, its status and comments.
|
||||
store: Entity<RepoStore>,
|
||||
/// Event id of the root PR event, kind 1618.
|
||||
/// Updates are revisions.
|
||||
/// Event id of the root PR event, kind 1618. Updates are revisions.
|
||||
pr_id: EventId,
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
/// Display name of the repository, for panels opened from here.
|
||||
repo_name: SharedString,
|
||||
/// Local clone the PR's git changes come from.
|
||||
worktree: Option<PathBuf>,
|
||||
/// Root PR's content, shown as plain text.
|
||||
description: SharedString,
|
||||
/// Tip commit of the PR, the latest update's `c` tag or the root's.
|
||||
/// Tip commit of the PR, from the latest update's `c` tag or the root.
|
||||
current_commit: Option<SharedString>,
|
||||
/// Commits of the patch series, in patch order, oldest first.
|
||||
commits: Vec<FileCommit>,
|
||||
/// The patch is being parsed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
/// Root PR inputs the in-flight diff load was started for.
|
||||
bound: Option<PrBinding>,
|
||||
/// Generation of the in-flight diff load. Stale results are discarded.
|
||||
load_generation: u64,
|
||||
/// 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
active_tab: usize,
|
||||
/// Changed-files explorer and per-file diff, like the commit and compare views.
|
||||
pane: Entity<DiffPane>,
|
||||
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
|
||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the commits tab.
|
||||
commit_scroll_handle: VirtualListScrollHandle,
|
||||
/// The dock caches item panels, so without this observer a panel opened
|
||||
/// before the store loaded would stay on its placeholder.
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestDetailView {
|
||||
@@ -85,9 +97,11 @@ impl PullRequestDetailView {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx));
|
||||
|
||||
// Defer loading until the window is ready, like the commit diff view.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.sync(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -103,156 +117,217 @@ impl PullRequestDetailView {
|
||||
commits: Vec::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
bound: None,
|
||||
load_generation: 0,
|
||||
active_tab: 0,
|
||||
pane,
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the PR events from the store.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
/// Snapshot the root PR from the store and reload the diff when it changed.
|
||||
///
|
||||
/// Re-runs on construction and on every store refresh. Item panels are
|
||||
/// cached by the dock, so this is the only way a panel opened before the
|
||||
/// store's first pass learns about its PR.
|
||||
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||
let loaded = self.store.read(cx).loaded;
|
||||
|
||||
let binding = {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
store.addr().and_then(|addr| {
|
||||
store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| {
|
||||
let update = latest_update(store.pull_requests.iter(), root);
|
||||
|
||||
let tip = update
|
||||
.and_then(current_commit_of)
|
||||
.or_else(|| current_commit_of(root));
|
||||
|
||||
let base = update
|
||||
.and_then(merge_base_of)
|
||||
.or_else(|| merge_base_of(root));
|
||||
|
||||
let clone_urls = clone_urls_of(root)
|
||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
PrBinding {
|
||||
description: root.content.clone(),
|
||||
patch: pull_request_patch(root, store.patches.iter()),
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr: addr.clone(),
|
||||
has_patch_link: root.tags.event_ids().next().is_some(),
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let Some(binding) = binding else {
|
||||
self.sync_missing(loaded, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
if self.bound.as_ref() == Some(&binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.bound = Some(binding.clone());
|
||||
self.load_diff(binding, cx);
|
||||
}
|
||||
|
||||
/// The store does not hold the root PR yet, or at all.
|
||||
///
|
||||
/// Loading until the first pass is applied, not found afterwards.
|
||||
fn sync_missing(&mut self, loaded: bool, cx: &mut Context<Self>) {
|
||||
self.bound = None;
|
||||
|
||||
if !loaded {
|
||||
if !self.loading || self.error.is_some() {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.error.as_deref() != Some(NOT_FOUND) {
|
||||
self.loading = false;
|
||||
self.error = Some(NOT_FOUND.into());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bound PR's changed files and commits.
|
||||
///
|
||||
/// Nostr-backed pull requests parse the patch series, git-backed ones fetch
|
||||
/// the clone and diff the `merge-base..tip` range.
|
||||
fn load_diff(&mut self, binding: PrBinding, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.description = binding.description.clone().into();
|
||||
self.current_commit = binding.tip.clone().map(SharedString::from);
|
||||
cx.notify();
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
|
||||
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
||||
let store = self.store.read(cx);
|
||||
self.load_generation = self.load_generation.wrapping_add(1);
|
||||
let generation = self.load_generation;
|
||||
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
self.loading = false;
|
||||
self.error = Some("Pull request not found".into());
|
||||
cx.notify();
|
||||
return;
|
||||
let PrBinding {
|
||||
patch,
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr,
|
||||
has_patch_link,
|
||||
..
|
||||
} = binding;
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_diffs(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_commits(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||
// Fetch the clone and diff the `merge-base..tip` range.
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let update = latest_update(store.pull_requests.iter(), root);
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = base.clone();
|
||||
let tip = tip.clone();
|
||||
|
||||
let tip = update
|
||||
.and_then(current_commit_of)
|
||||
.or_else(|| current_commit_of(root));
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
|
||||
let base = update
|
||||
.and_then(merge_base_of)
|
||||
.or_else(|| merge_base_of(root));
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
|
||||
let clone_urls = clone_urls_of(root)
|
||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()));
|
||||
let tip =
|
||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
|
||||
(
|
||||
root.content.clone(),
|
||||
pull_request_patch(root, store.patches.iter()),
|
||||
tip,
|
||||
base,
|
||||
clone_urls.unwrap_or_default(),
|
||||
store.addr().clone(),
|
||||
root.tags.event_ids().next().is_some(),
|
||||
)
|
||||
};
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||
None => {
|
||||
let head = repo
|
||||
.head_id()
|
||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
self.description = description.into();
|
||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||
let commits =
|
||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_diffs(&patch) }
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await;
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_commits(&patch) }
|
||||
})
|
||||
.await;
|
||||
let (diff, commits, worktree) = match git {
|
||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||
None => (nostr_diff, nostr_commits, None),
|
||||
};
|
||||
|
||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||
// Fetch the clone and diff the `merge-base..tip` range.
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
// A newer binding superseded this load.
|
||||
if this.load_generation != generation {
|
||||
return;
|
||||
}
|
||||
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = merge_base.clone();
|
||||
let tip = current_commit.clone();
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||
this.commits = commits;
|
||||
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
|
||||
let tip = tip
|
||||
.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||
None => {
|
||||
let head = repo
|
||||
.head_id()
|
||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let diff =
|
||||
signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||
let commits =
|
||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
||||
let (diff, commits, worktree) = match git {
|
||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||
None => (nostr_diff, nostr_commits, None),
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.current_commit = current_commit.map(SharedString::from);
|
||||
this.commit_item_sizes =
|
||||
Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||
this.commits = commits;
|
||||
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
@@ -435,9 +510,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Full-height Commits tab.
|
||||
///
|
||||
/// Every commit of the patch series, or a status message while loading or empty.
|
||||
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -485,9 +557,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row of the commits tab, id, summary, author and time.
|
||||
///
|
||||
/// Clicking a row opens the commit's diff in the bottom dock.
|
||||
fn render_commit_row(
|
||||
&self,
|
||||
ix: usize,
|
||||
@@ -540,7 +609,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Always-visible header with a status badge and title, like the issue panel.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let current_commit = self.current_commit.clone();
|
||||
let (title, status, branch, author) = {
|
||||
@@ -642,7 +710,6 @@ impl PullRequestDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the update pull request dialog.
|
||||
fn open_update_pull_request_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
root: Event,
|
||||
@@ -703,7 +770,6 @@ fn open_update_pull_request_dialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// The `c` tag of a PR event, the commit the proposal points at.
|
||||
/// One-line commit metadata for the commits list.
|
||||
///
|
||||
/// Author and relative time, whichever is available.
|
||||
@@ -761,26 +827,3 @@ impl Render for PullRequestDetailView {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
|
||||
#[test]
|
||||
fn commit_meta_combines_author_and_time() {
|
||||
let commit = |author: &str, time: i64| FileCommit {
|
||||
id: COMMIT_HEX.into(),
|
||||
summary: "summary".into(),
|
||||
description: None,
|
||||
author: author.into(),
|
||||
time,
|
||||
};
|
||||
|
||||
assert_eq!(commit_meta(&commit("Alice", 0)), "Alice");
|
||||
assert_eq!(commit_meta(&commit("", 0)), "");
|
||||
assert!(!commit_meta(&commit("", 1_000_000)).is_empty());
|
||||
assert!(!commit_meta(&commit("Alice", 1_000_000)).is_empty());
|
||||
}
|
||||
}
|
||||
+75
-80
@@ -4,8 +4,8 @@ use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::alert::Alert;
|
||||
@@ -19,31 +19,26 @@ use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::RepoAction;
|
||||
use super::new_pull_request::open_new_pull_panel;
|
||||
use super::pull_request_detail::PullRequestDetailView;
|
||||
pub(super) mod detail;
|
||||
pub(super) mod new;
|
||||
|
||||
use self::detail::PullRequestDetailView;
|
||||
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,
|
||||
@@ -57,37 +52,39 @@ impl PullRequestFilter {
|
||||
|
||||
pub struct PullRequestsView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the detail panels are added to.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the pull requests and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Filter selected in the header filter buttons.
|
||||
filter: PullRequestFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// The filtered pull request count [`Self::item_sizes`] was built for.
|
||||
pr_len: usize,
|
||||
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
||||
visible_prs: Vec<usize>,
|
||||
/// Header counts `(total, open, closed, draft, merged)`.
|
||||
counts: (usize, usize, usize, usize, usize),
|
||||
/// Store version and filter the cached rows/counts were built from.
|
||||
cache_key: Option<(u64, PullRequestFilter)>,
|
||||
/// Virtual list state of the pull requests list.
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: PullRequestFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestsView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -95,15 +92,61 @@ impl PullRequestsView {
|
||||
repo_name,
|
||||
filter: PullRequestFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
pr_len: 0,
|
||||
visible_prs: Vec::new(),
|
||||
counts: (0, 0, 0, 0, 0),
|
||||
cache_key: None,
|
||||
synced_filter: PullRequestFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the detail panel of `pr_id` in the dock area.
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_prs, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
|
||||
let visible_prs: Vec<usize> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, pr)| {
|
||||
if pr.kind != Kind::GitPullRequest {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = store.status_of(pr);
|
||||
counts.0 += 1;
|
||||
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft => counts.3 += 1,
|
||||
RepoStatus::Applied => counts.4 += 1,
|
||||
}
|
||||
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible_prs, counts)
|
||||
};
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
|
||||
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
||||
return;
|
||||
}
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); visible_prs.len()]);
|
||||
self.visible_prs = visible_prs;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_pull_request_detail(
|
||||
&mut self,
|
||||
pr_id: EventId,
|
||||
@@ -129,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;
|
||||
@@ -196,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()
|
||||
@@ -217,7 +256,7 @@ impl PullRequestsView {
|
||||
.selected(self.filter == PullRequestFilter::All)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::All;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -227,7 +266,7 @@ impl PullRequestsView {
|
||||
.selected(self.filter == PullRequestFilter::Open)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Open;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -237,7 +276,7 @@ impl PullRequestsView {
|
||||
.selected(self.filter == PullRequestFilter::Closed)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Closed;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -247,7 +286,7 @@ impl PullRequestsView {
|
||||
.selected(self.filter == PullRequestFilter::Draft)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Draft;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -257,7 +296,7 @@ impl PullRequestsView {
|
||||
.selected(self.filter == PullRequestFilter::Merged)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Merged;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -330,55 +369,11 @@ impl Focusable for PullRequestsView {
|
||||
impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||
let version = self.store.read(cx).version();
|
||||
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
self.visible_prs = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, pr)| {
|
||||
if pr.kind != Kind::GitPullRequest {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = store.status_of(pr);
|
||||
counts.0 += 1;
|
||||
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft => counts.3 += 1,
|
||||
RepoStatus::Applied => counts.4 += 1,
|
||||
}
|
||||
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.counts = counts;
|
||||
self.cache_key = Some((version, filter));
|
||||
}
|
||||
|
||||
let count = self.visible_prs.len();
|
||||
|
||||
// The virtual list's item count comes from `item_sizes`.
|
||||
// Rebuild it whenever the filtered pull request count changes.
|
||||
if count != self.pr_len {
|
||||
self.pr_len = count;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]);
|
||||
}
|
||||
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let view = cx.entity().clone();
|
||||
|
||||
// Non-fatal warnings and errors of the last action, like creating or updating a PR.
|
||||
// Shown as dismissible banners above the list.
|
||||
let (last_error, last_warning) = {
|
||||
let store = self.store.read(cx);
|
||||
(store.last_error.clone(), store.last_warning.clone())
|
||||
+46
-80
@@ -27,26 +27,19 @@ use signed_git::{
|
||||
worktree_commit_range_commits, worktree_commit_range_diff,
|
||||
};
|
||||
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
|
||||
use signed_ui::{CountBadge, placeholder};
|
||||
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
||||
|
||||
use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::ref_selector_trigger;
|
||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
||||
|
||||
/// The new pull request panel of a repository.
|
||||
pub struct NewPullRequestView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the panel lives in, commit diffs are opened there.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Store of the target repository, source of the announced HEAD default.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// The user's local checkout.
|
||||
repo_path: Option<PathBuf>,
|
||||
/// Branches of the checkout, backing both selectors in checkout mode.
|
||||
/// Backs both selectors in checkout mode.
|
||||
branches: Vec<SharedString>,
|
||||
/// Fork-backed compare state.
|
||||
fork: Option<ForkCompare>,
|
||||
/// Selected base branch, the PR target, stored as a short name.
|
||||
base: SharedString,
|
||||
@@ -54,33 +47,27 @@ pub struct NewPullRequestView {
|
||||
compare: SharedString,
|
||||
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Title input, required.
|
||||
subject: Entity<InputState>,
|
||||
/// Description input, optional.
|
||||
description: Entity<TextareaState>,
|
||||
/// Merge base of the selected branches, `None` until the compare loads.
|
||||
merge_base: Option<String>,
|
||||
/// Commits in `merge_base..compare`, newest first.
|
||||
commits: Option<Vec<signed_git::FileCommit>>,
|
||||
/// The compare is being computed.
|
||||
loading: bool,
|
||||
/// Error of the last compare or submit attempt.
|
||||
error: Option<SharedString>,
|
||||
/// A submit, patch generation and publish, is in flight.
|
||||
submitting: bool,
|
||||
/// Bumped on every branch switch, stale compare results are discarded.
|
||||
compare_generation: u64,
|
||||
/// Active tab, 0 = Files and 1 = Commits.
|
||||
/// 0 = Files, 1 = Commits.
|
||||
active_tab: usize,
|
||||
/// The compare diff, the Files tab body.
|
||||
pane: Entity<DiffPane>,
|
||||
/// Virtual list state of the Commits tab.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A fork-backed compare.
|
||||
struct ForkCompare {
|
||||
/// Fork announcement the compare branch is imported from.
|
||||
announcement: Announcement,
|
||||
@@ -91,20 +78,15 @@ struct ForkCompare {
|
||||
}
|
||||
|
||||
impl ForkCompare {
|
||||
/// The full ref of the base branch `name` in the mirror.
|
||||
fn base_ref(name: &str) -> String {
|
||||
format!("refs/remotes/origin/{name}")
|
||||
}
|
||||
|
||||
/// The full ref of the compare branch `name` in the mirror.
|
||||
fn compare_ref(&self, name: &str) -> String {
|
||||
format!("refs/fork/{}/{}", self.namespace, name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The display name of an announcement.
|
||||
///
|
||||
/// Its human-readable name, falling back to the repository id.
|
||||
fn fork_display_name(announcement: &Announcement) -> SharedString {
|
||||
announcement
|
||||
.name
|
||||
@@ -113,7 +95,6 @@ fn fork_display_name(announcement: &Announcement) -> SharedString {
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
}
|
||||
|
||||
/// A short label of a fork's owner for the source picker, a hex prefix.
|
||||
fn shorten_owner(owner: &PublicKey) -> String {
|
||||
let hex = owner.to_hex();
|
||||
hex.chars().take(10).collect()
|
||||
@@ -132,9 +113,6 @@ fn truncate_label(label: &str) -> SharedString {
|
||||
SharedString::from(label)
|
||||
}
|
||||
|
||||
/// The compare-source menu entry of one local checkout folder.
|
||||
///
|
||||
/// Applies the folder directly, no picker.
|
||||
fn checkout_source_item(
|
||||
view: WeakEntity<NewPullRequestView>,
|
||||
path: PathBuf,
|
||||
@@ -163,7 +141,6 @@ fn checkout_source_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// The compare-source menu entry prompting for an arbitrary folder.
|
||||
fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuItem {
|
||||
PopupMenuItem::element(move |_window, cx| {
|
||||
source_row(
|
||||
@@ -180,9 +157,6 @@ fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuI
|
||||
})
|
||||
}
|
||||
|
||||
/// The compare-source menu entry of one announced fork.
|
||||
///
|
||||
/// Imports its branches into the target's mirror and switches the panel to fork mode.
|
||||
fn fork_source_item(
|
||||
view: WeakEntity<NewPullRequestView>,
|
||||
announcement: Announcement,
|
||||
@@ -208,7 +182,6 @@ fn fork_source_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of the compare-source menu, icon, title and a muted subtitle.
|
||||
fn source_row<T>(icon: impl Into<Icon>, title: T, subtitle: T, cx: &App) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
@@ -306,7 +279,24 @@ impl NewPullRequestView {
|
||||
),
|
||||
];
|
||||
|
||||
let mut view = Self {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
let Some(addr) = this.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(path) = CheckoutsStore::global(cx)
|
||||
.read(cx)
|
||||
.associations_of(&addr)
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.apply_folder_path(path, window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
@@ -331,23 +321,9 @@ impl NewPullRequestView {
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
// Prefill with the store's freshest associated checkout, no folder dialog.
|
||||
let addr = view.store.read(cx).addr().clone();
|
||||
if let Some(path) = CheckoutsStore::global(cx)
|
||||
.read(cx)
|
||||
.associations_of(&addr)
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
view.apply_folder_path(path, window, cx);
|
||||
}
|
||||
|
||||
view
|
||||
}
|
||||
|
||||
/// Whether a compare source, a checkout or a fork, is applied.
|
||||
fn has_source(&self) -> bool {
|
||||
self.repo_path.is_some() || self.fork.is_some()
|
||||
}
|
||||
@@ -384,7 +360,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for a local checkout.
|
||||
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
@@ -415,9 +390,7 @@ impl NewPullRequestView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Apply `path` as the local checkout, no picker.
|
||||
///
|
||||
/// Branches and current branch are read off the UI thread, then applied.
|
||||
/// Branches and the current branch are read off the UI thread, then applied.
|
||||
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let path = path.to_string_lossy().to_string();
|
||||
|
||||
@@ -446,7 +419,6 @@ impl NewPullRequestView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Apply a picked checkout, filling the selectors and loading the compare.
|
||||
fn apply_checkout(
|
||||
&mut self,
|
||||
path: String,
|
||||
@@ -497,11 +469,12 @@ impl NewPullRequestView {
|
||||
|
||||
// Remember this folder as a checkout of the target repository.
|
||||
// The next panel pre-fills it.
|
||||
let addr = self.store.read(cx).addr().clone();
|
||||
let checkout_store = CheckoutsStore::global(cx);
|
||||
checkout_store.update(cx, |store, cx| {
|
||||
store.record(PathBuf::from(&path), addr, cx);
|
||||
});
|
||||
if let Some(addr) = self.store.read(cx).addr().cloned() {
|
||||
let checkout_store = CheckoutsStore::global(cx);
|
||||
checkout_store.update(cx, |store, cx| {
|
||||
store.record(PathBuf::from(&path), addr, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let branches = self.branches.clone();
|
||||
let base = SharedString::from(base.clone());
|
||||
@@ -523,20 +496,21 @@ impl NewPullRequestView {
|
||||
self.reload_compare(window, cx);
|
||||
}
|
||||
|
||||
/// The base repository of the panel, its address and announced EUC.
|
||||
///
|
||||
/// Used to find fork candidates.
|
||||
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
|
||||
/// Used to find fork candidates. `None` while the repository is not announced.
|
||||
fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option<String>)> {
|
||||
let store = self.store.read(cx);
|
||||
let addr = store.addr()?.clone();
|
||||
let euc = store.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
(store.addr().clone(), euc)
|
||||
Some((addr, euc))
|
||||
}
|
||||
|
||||
/// Announced forks of the target repository a compare can use, own first.
|
||||
///
|
||||
/// Re-read whenever the picker opens.
|
||||
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
|
||||
let (base, euc) = self.base_repo(cx);
|
||||
let Some((base, euc)) = self.base_repo(cx) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let user = Backend::global(cx).read(cx).current_user();
|
||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||
fork_candidates(&announcements, &base, euc.as_deref(), user)
|
||||
@@ -545,7 +519,6 @@ impl NewPullRequestView {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compare against an announced fork.
|
||||
fn choose_fork(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -557,7 +530,9 @@ impl NewPullRequestView {
|
||||
.as_ref()
|
||||
.is_some_and(|fork| fork.announcement.addr() == announcement.addr());
|
||||
|
||||
let (base, _euc) = self.base_repo(cx);
|
||||
let Some((base, _euc)) = self.base_repo(cx) else {
|
||||
return;
|
||||
};
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let mirror_path = cache.repo_path(&base);
|
||||
let namespace = fork_namespace(&announcement);
|
||||
@@ -598,9 +573,6 @@ impl NewPullRequestView {
|
||||
let clone_urls = clone_urls.clone();
|
||||
let mirror_path = mirror_path.clone();
|
||||
async move {
|
||||
// The fork and base must share history for a merge-base to exist.
|
||||
// The target's mirror is the object store both sides land in.
|
||||
// `ensure_clone` fetches `origin` when the mirror already exists.
|
||||
cache.ensure_clone(&base, &base_clone_urls)?;
|
||||
|
||||
// Prune stale imports of any fork.
|
||||
@@ -669,7 +641,6 @@ impl NewPullRequestView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Apply an imported fork, filling the selectors and loading the compare.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_fork(
|
||||
&mut self,
|
||||
@@ -774,7 +745,6 @@ impl NewPullRequestView {
|
||||
self.reload_compare(window, cx);
|
||||
}
|
||||
|
||||
/// Recompute `merge_base..compare` of the selected branches on a background task.
|
||||
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
@@ -868,7 +838,6 @@ impl NewPullRequestView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Publish the pull request.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting || self.loading {
|
||||
return;
|
||||
@@ -950,7 +919,6 @@ impl NewPullRequestView {
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Open the diff of `commit_id`, from the Commits tab, in a new panel.
|
||||
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
@@ -1108,7 +1076,6 @@ impl NewPullRequestView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The source picker's trigger, a truncated label of the applied source.
|
||||
fn source_trigger(&self) -> SharedString {
|
||||
match &self.fork {
|
||||
Some(fork) => truncate_label(&fork_display_name(&fork.announcement)),
|
||||
@@ -1119,14 +1086,17 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the compare-source menu.
|
||||
fn source_menu(
|
||||
&self,
|
||||
cx: &Context<Self>,
|
||||
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
|
||||
let view = cx.entity().downgrade();
|
||||
let addr = self.store.read(cx).addr().clone();
|
||||
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
|
||||
let associated = self
|
||||
.store
|
||||
.read(cx)
|
||||
.addr()
|
||||
.map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr))
|
||||
.unwrap_or_default();
|
||||
|
||||
let active_path = (self.fork.is_none())
|
||||
.then(|| self.repo_path.clone())
|
||||
@@ -1172,7 +1142,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title and description inputs.
|
||||
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.px_4()
|
||||
@@ -1183,7 +1152,6 @@ impl NewPullRequestView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The Files/Commits tab bar, mirroring the repository panel's.
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len());
|
||||
let commits = self.commits.as_ref().map_or(0, |commits| commits.len());
|
||||
@@ -1255,7 +1223,6 @@ impl NewPullRequestView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The active tab's body.
|
||||
fn render_content(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -1341,8 +1308,7 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the new pull request panel in the center dock.
|
||||
pub(super) fn open_new_pull_panel(
|
||||
pub(crate) fn open_new_pull_panel(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
-12
@@ -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()
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, WeakEntity, Window};
|
||||
use gpui_base::dock::PanelView;
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::issues::detail::IssueDetailView;
|
||||
use crate::views::pull_requests::detail::PullRequestDetailView;
|
||||
|
||||
/// Open repository as a panel in the dock's center.
|
||||
pub(crate) fn open_repo_panel(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<RepoDetailView> {
|
||||
let detail = cx
|
||||
.new(|cx| RepoDetailView::new(dock_area.clone(), addr.clone(), hint.cloned(), window, cx));
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(detail.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
detail
|
||||
}
|
||||
|
||||
/// The nostr store of `addr`'s repository, without opening a repository panel.
|
||||
fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity<RepoStore> {
|
||||
cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx))
|
||||
}
|
||||
|
||||
/// An item of a repository to open from outside its detail panel.
|
||||
pub(crate) enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// The repository store is built here.
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel: Arc<dyn PanelView> =
|
||||
match item {
|
||||
RepoItem::Issue(issue_id) => {
|
||||
let store = repo_store(addr, hint, cx);
|
||||
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
||||
}
|
||||
RepoItem::PullRequest(pr_id) => {
|
||||
let store = repo_store(addr, hint, cx);
|
||||
panel_handle(cx.new(|cx| {
|
||||
PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)
|
||||
}))
|
||||
}
|
||||
RepoItem::Patch => return,
|
||||
};
|
||||
|
||||
let Some(dock_area) = dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel, window, cx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use signed_state::CheckoutStatus;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Banners {
|
||||
dismissed: HashSet<(PathBuf, String)>,
|
||||
ready_requested: bool,
|
||||
/// Re-requested only when the announced HEAD or the base default changes.
|
||||
ready_head: Option<String>,
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
}
|
||||
|
||||
impl Banners {
|
||||
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
|
||||
self.dismissed
|
||||
.contains(&(status.path.clone(), status.branch.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
|
||||
self.dismissed
|
||||
.insert((status.path.clone(), status.branch.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
|
||||
(self.ready_requested, &self.ready_head)
|
||||
}
|
||||
|
||||
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
|
||||
self.ready_requested = true;
|
||||
self.ready_head = head;
|
||||
}
|
||||
|
||||
pub(super) fn set_statuses(
|
||||
&mut self,
|
||||
ready: Vec<CheckoutStatus>,
|
||||
push: Vec<CheckoutStatus>,
|
||||
) -> bool {
|
||||
let changed = ready != self.ready_statuses || push != self.push_statuses;
|
||||
self.ready_statuses = ready;
|
||||
self.push_statuses = push;
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Editor, EditorState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_git::{FileCommit, WorktreeSnapshot};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use crate::views::tree::{TreeItemSeed, tree_items};
|
||||
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
const MAX_PREVIEWED_FILES: usize = 32;
|
||||
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
enum FileContent {
|
||||
Text(String),
|
||||
Binary,
|
||||
TooLarge,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct MarkdownView {
|
||||
/// `None` means the repository README.
|
||||
path: Option<SharedString>,
|
||||
state: Entity<TextViewState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
path: SharedString,
|
||||
state: Entity<EditorState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
pub(super) struct RepoFilesView {
|
||||
tree_state: Entity<TreeState>,
|
||||
worktree: Option<PathBuf>,
|
||||
worktree_paths: Vec<String>,
|
||||
md: Option<MarkdownView>,
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
file_order: VecDeque<String>,
|
||||
preview_bytes: usize,
|
||||
loading_files: HashSet<String>,
|
||||
commits: HashMap<String, FileCommit>,
|
||||
pending_commits: Vec<String>,
|
||||
loading_commits: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoFilesView {
|
||||
pub(super) fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
tree_state: cx.new(|cx| TreeState::new(cx)),
|
||||
worktree: None,
|
||||
worktree_paths: Vec::new(),
|
||||
md: None,
|
||||
code: None,
|
||||
readme_name: None,
|
||||
selected_file: None,
|
||||
files: HashMap::new(),
|
||||
file_order: VecDeque::new(),
|
||||
preview_bytes: 0,
|
||||
loading_files: HashSet::new(),
|
||||
commits: HashMap::new(),
|
||||
pending_commits: Vec::new(),
|
||||
loading_commits: false,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_worktree(&mut self, path: PathBuf) {
|
||||
self.worktree = Some(path);
|
||||
}
|
||||
|
||||
pub(super) fn apply_entries(
|
||||
&mut self,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.worktree_paths = paths;
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(tree_items(tree, false), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Point the README pane at `path`/`bytes`, or clear it when absent.
|
||||
///
|
||||
/// Returns whether the pane changed.
|
||||
pub(super) fn set_readme(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some((path, bytes)) = path.zip(bytes) else {
|
||||
let changed = self.readme_name.is_some() || self.md.is_some();
|
||||
self.readme_name = None;
|
||||
self.md = None;
|
||||
return changed;
|
||||
};
|
||||
|
||||
let name: SharedString = path.to_string_lossy().into();
|
||||
let mut changed = self.readme_name.as_ref() != Some(&name);
|
||||
self.readme_name = Some(name);
|
||||
self.load_commit(&path.to_string_lossy(), cx);
|
||||
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
changed |= self.set_markdown(None, &text, cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drop every cached preview and the README, e.g. on a branch switch.
|
||||
pub(super) fn clear_previews(&mut self) {
|
||||
self.selected_file = None;
|
||||
self.files.clear();
|
||||
self.file_order.clear();
|
||||
self.preview_bytes = 0;
|
||||
self.loading_files.clear();
|
||||
self.commits.clear();
|
||||
self.pending_commits.clear();
|
||||
self.loading_commits = false;
|
||||
self.md = None;
|
||||
self.code = None;
|
||||
self.readme_name = None;
|
||||
}
|
||||
|
||||
/// Refresh after the mirror caught up with the remote.
|
||||
///
|
||||
/// Unlike a branch switch this keeps the selection and previews: it rebuilds
|
||||
/// the tree, drops previews of files the refresh removed and re-renders the
|
||||
/// README when it is on screen.
|
||||
///
|
||||
/// Returns whether the tree, a preview or the README changed.
|
||||
pub(super) fn catch_up(
|
||||
&mut self,
|
||||
snapshot: &WorktreeSnapshot,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if paths != self.worktree_paths {
|
||||
self.apply_entries(tree, paths, cx);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let present: HashSet<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut previewed: Vec<String> = Vec::new();
|
||||
|
||||
previewed.extend(self.files.keys().cloned());
|
||||
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
|
||||
|
||||
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
previewed.sort();
|
||||
previewed.dedup();
|
||||
|
||||
for path in previewed {
|
||||
if !present.contains(&path) {
|
||||
self.drop_preview_of(&path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if self.selected_file.is_none() {
|
||||
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn pane_title(&self) -> SharedString {
|
||||
self.selected_file
|
||||
.clone()
|
||||
.or_else(|| self.readme_name.clone())
|
||||
.unwrap_or_else(|| "Overview".into())
|
||||
}
|
||||
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1().min_h_0().child(tree(
|
||||
&tree_state,
|
||||
move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()), cx)
|
||||
} else {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None, cx)
|
||||
} else {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().muted)
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.xsmall()
|
||||
.text()
|
||||
.label(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("repo-content").flex_1().min_h_0().child(body))
|
||||
}
|
||||
|
||||
fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(md) = &self.md
|
||||
&& md.path == path
|
||||
&& md.source_hash == hash
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let state = cx.new(|cx| TextViewState::markdown("", cx));
|
||||
state.update(cx, |state, cx| state.push_str(text, cx));
|
||||
|
||||
self.md = Some(MarkdownView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
TextView::new(&md.state)
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(code) = &self.code
|
||||
&& code.path == path
|
||||
&& code.source_hash == hash
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let language = code_language(path.as_ref()).unwrap_or("text");
|
||||
let state = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
.language(language)
|
||||
.default_value(text)
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
Editor::new(&code.state)
|
||||
.readonly(true)
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
|
||||
if self.files.contains_key(path) {
|
||||
if let Some(FileContent::Text(text)) = self.files.get(path) {
|
||||
let text = text.clone();
|
||||
if is_markdown_path(path) {
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
|
||||
self.set_markdown(Some(path.into()), &text, cx);
|
||||
}
|
||||
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
|
||||
self.set_code(path.into(), &text, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.loading_files.contains(path) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let rel = Path::new(path);
|
||||
let unsafe_path = rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if unsafe_path {
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
self.load_commit(&path, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let full = worktree.join(&path_for_read);
|
||||
|
||||
let metadata = match std::fs::metadata(&full) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
|
||||
return Ok(FileContent::TooLarge);
|
||||
}
|
||||
|
||||
let bytes = match std::fs::read(&full) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(text) => Ok(FileContent::Text(text)),
|
||||
Err(_) => Ok(FileContent::Binary),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading_files.remove(&path);
|
||||
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
if let FileContent::Text(text) = &kind {
|
||||
if is_markdown_path(&path) {
|
||||
let same = this.md.as_ref().map(|md| md.path.as_deref())
|
||||
== Some(Some(path.as_str()));
|
||||
if !same {
|
||||
this.set_markdown(Some(path.clone().into()), text, cx);
|
||||
}
|
||||
} else {
|
||||
let same = this.code.as_ref().map(|code| code.path.as_str())
|
||||
== Some(path.as_str());
|
||||
if !same {
|
||||
this.set_code(path.clone().into(), text, window, cx);
|
||||
}
|
||||
}
|
||||
this.preview_bytes += text.len();
|
||||
}
|
||||
this.files.insert(path.clone(), kind);
|
||||
this.file_order.push_back(path);
|
||||
this.evict_previews();
|
||||
}
|
||||
Err(error) => {
|
||||
this.files
|
||||
.insert(path, FileContent::Failed(error.to_string()));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn drop_preview_of(&mut self, path: &str) {
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
self.commits.remove(path);
|
||||
|
||||
if self.selected_file.as_deref() == Some(path) {
|
||||
self.selected_file = None;
|
||||
}
|
||||
|
||||
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
|
||||
self.md = None;
|
||||
}
|
||||
|
||||
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
|
||||
self.code = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_previews(&mut self) {
|
||||
while (self.files.len() > MAX_PREVIEWED_FILES
|
||||
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
|
||||
&& self.file_order.len() > 1
|
||||
{
|
||||
let path = self.file_order.pop_front().expect("non-empty");
|
||||
|
||||
if Some(path.as_str()) == self.selected_file.as_deref() {
|
||||
self.file_order.push_back(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
|
||||
self.md = None;
|
||||
}
|
||||
|
||||
if self
|
||||
.code
|
||||
.as_ref()
|
||||
.is_some_and(|code| code.path.as_ref() == path.as_str())
|
||||
{
|
||||
self.code = None;
|
||||
}
|
||||
|
||||
self.commits.remove(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_commits.push(path.to_string());
|
||||
|
||||
if !self.loading_commits {
|
||||
self.load_commits(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commits(&mut self, cx: &mut Context<Self>) {
|
||||
if self.pending_commits.is_empty() || self.loading_commits {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
self.pending_commits.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_commits = true;
|
||||
|
||||
let paths = std::mem::take(&mut self.pending_commits);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
let result = cx
|
||||
.background_spawn(
|
||||
async move { signed_git::worktree_last_commits(&worktree, &rels) },
|
||||
)
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.loading_commits = false;
|
||||
|
||||
if let Ok(found) = result {
|
||||
for (path, commit) in found {
|
||||
this.commits
|
||||
.insert(path.to_string_lossy().into_owned(), commit);
|
||||
}
|
||||
}
|
||||
|
||||
if !this.pending_commits.is_empty() {
|
||||
this.load_commits(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoFilesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
let pane_title = self.pane_title();
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(Self::render_tree_column(tree_state, view, cx))
|
||||
.child(self.render_content_column(pane_title, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn source_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Some common files are recognized by name rather than extension.
|
||||
match name {
|
||||
"Makefile" | "makefile" => return Some("make"),
|
||||
"CMakeLists.txt" => return Some("cmake"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"rs" => "rust",
|
||||
"toml" => "toml",
|
||||
"json" | "jsonc" => "json",
|
||||
"py" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" | "jsx" => "tsx",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"java" => "java",
|
||||
"kt" | "kts" | "ktm" => "kotlin",
|
||||
"swift" => "swift",
|
||||
"php" | "phtml" => "php",
|
||||
"rb" => "ruby",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"yml" | "yaml" => "yaml",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"html" | "htm" => "html",
|
||||
"lua" => "lua",
|
||||
"sql" => "sql",
|
||||
"proto" | "protobuf" => "proto",
|
||||
"cmake" => "cmake",
|
||||
"zig" => "zig",
|
||||
"ex" | "exs" => "elixir",
|
||||
"graphql" | "gql" => "graphql",
|
||||
"diff" | "patch" => "diff",
|
||||
"svelte" => "svelte",
|
||||
"astro" => "astro",
|
||||
"scala" => "scala",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
|
||||
use signed_git::CommitList;
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::placeholder;
|
||||
|
||||
use super::repo_display_name;
|
||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row};
|
||||
|
||||
pub(super) struct RepoHistoryView {
|
||||
store: Entity<RepoStore>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
worktree: Option<PathBuf>,
|
||||
all_commits: Option<CommitList>,
|
||||
loading_all_commits: bool,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoHistoryView {
|
||||
pub(super) fn new(store: Entity<RepoStore>, dock_area: WeakEntity<DockArea>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
worktree: None,
|
||||
all_commits: None,
|
||||
loading_all_commits: false,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_worktree(&mut self, path: Option<PathBuf>) {
|
||||
self.worktree = path;
|
||||
}
|
||||
|
||||
/// Number of commits reachable from HEAD, for the Commits tab badge.
|
||||
pub(super) fn commit_count(&self) -> Option<usize> {
|
||||
self.all_commits.as_ref().map(|list| list.total)
|
||||
}
|
||||
|
||||
/// Drop the current list and walk HEAD again.
|
||||
pub(super) fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.all_commits = None;
|
||||
self.loading_all_commits = false;
|
||||
self.load(cx);
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading_all_commits || self.all_commits.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_all_commits = true;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if let Ok(list) = result {
|
||||
let count = list.commits.len();
|
||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||
this.all_commits = Some(list);
|
||||
}
|
||||
|
||||
this.loading_all_commits = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub(super) fn open_commit_diff(
|
||||
&mut self,
|
||||
commit_id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Same display name as the repo detail panel's title.
|
||||
let repo_name = repo_display_name(self.store.read(cx));
|
||||
|
||||
let panel =
|
||||
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoHistoryView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
-6
@@ -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>),
|
||||
+929
-1573
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window};
|
||||
use gpui_component::combobox::ComboboxState;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
|
||||
pub(super) struct RefSwitcher {
|
||||
pub(super) branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
pub(super) tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
ref_branches: Vec<SharedString>,
|
||||
ref_tags: Vec<SharedString>,
|
||||
pub(super) switching_ref: bool,
|
||||
}
|
||||
|
||||
impl RefSwitcher {
|
||||
pub(super) fn new(window: &mut Window, cx: &mut App) -> Self {
|
||||
let branch_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
let tag_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
|
||||
Self {
|
||||
branch_select,
|
||||
tag_select,
|
||||
ref_branches: Vec::new(),
|
||||
ref_tags: Vec::new(),
|
||||
switching_ref: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_branches(
|
||||
&mut self,
|
||||
branches: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
selected,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn set_tags(
|
||||
&mut self,
|
||||
tags: Vec<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
|
||||
}
|
||||
|
||||
pub(super) fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
select.update(cx, |state, cx| match previous {
|
||||
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
|
||||
None => state.clear_selection(cx),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_selector(
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_items(SearchableVec::from(items.clone()), window, cx);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected_values(std::slice::from_ref(&value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Editor, EditorState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::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.
|
||||
pub(super) path: Option<SharedString>,
|
||||
pub(super) state: Entity<TextViewState>,
|
||||
}
|
||||
|
||||
/// A code file loaded into a persistent [`InputState`].
|
||||
pub(super) struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
pub(super) path: SharedString,
|
||||
pub(super) state: Entity<EditorState>,
|
||||
}
|
||||
|
||||
/// Spinner shown while a document is being loaded/parsed.
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// One row of the file tree with icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the file tree.
|
||||
pub(super) fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1().min_h_0().child(tree(
|
||||
&tree_state,
|
||||
move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// Right column, README, selected file preview or status text.
|
||||
pub(super) fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let loading = self.loading;
|
||||
let error = self.error.clone();
|
||||
let selected_file = self.selected_file.clone();
|
||||
|
||||
let body: AnyElement = if loading {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(Spinner::new().small())
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Cloning repository..."),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if let Some(error) = error {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(error),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if let Some(path) = selected_file {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()), cx)
|
||||
} else {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None, cx)
|
||||
} else {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
// Computed after the body above, which needs `&mut self`.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().muted)
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.xsmall()
|
||||
.text()
|
||||
.label(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.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>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let state = cx.new(|cx| TextViewState::markdown("", cx));
|
||||
state.update(cx, |state, cx| state.push_str(text, cx));
|
||||
self.md = Some(MarkdownView { path, state });
|
||||
}
|
||||
|
||||
/// 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();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
TextView::new(&md.state)
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_sm()
|
||||
.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,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let language = code_language(path.as_ref()).unwrap_or("text");
|
||||
let state = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
.language(language)
|
||||
.default_value(text)
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
self.code = Some(CodeView { path, state });
|
||||
}
|
||||
|
||||
/// 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();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
Editor::new(&code.state)
|
||||
.readonly(true)
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Context, Window, div, px};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
|
||||
use signed_git::FileCommit;
|
||||
use signed_ui::placeholder;
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::RepoDetailView;
|
||||
|
||||
/// Height of one commit row in the virtual list.
|
||||
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(super) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
// Copy only the values the element tree needs.
|
||||
// The list is borrowed by the renderer below instead of cloned per frame.
|
||||
// A full history can be tens of thousands of commits.
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
// The history is capped.
|
||||
// Tell the user the list is truncated.
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::TreeItem;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
|
||||
use utils::relative_time;
|
||||
|
||||
pub(super) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
id: String,
|
||||
/// File or directory name.
|
||||
label: String,
|
||||
children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
item = item.expanded(true);
|
||||
}
|
||||
item.children = seed
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect();
|
||||
item
|
||||
}
|
||||
|
||||
seeds
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Some common files are recognized by name rather than extension.
|
||||
match name {
|
||||
"Makefile" | "makefile" => return Some("make"),
|
||||
"CMakeLists.txt" => return Some("cmake"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"rs" => "rust",
|
||||
"toml" => "toml",
|
||||
"json" | "jsonc" => "json",
|
||||
"py" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" | "jsx" => "tsx",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"java" => "java",
|
||||
"kt" | "kts" | "ktm" => "kotlin",
|
||||
"swift" => "swift",
|
||||
"php" | "phtml" => "php",
|
||||
"rb" => "ruby",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"yml" | "yaml" => "yaml",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"html" | "htm" => "html",
|
||||
"lua" => "lua",
|
||||
"sql" => "sql",
|
||||
"proto" | "protobuf" => "proto",
|
||||
"cmake" => "cmake",
|
||||
"zig" => "zig",
|
||||
"ex" | "exs" => "elixir",
|
||||
"graphql" | "gql" => "graphql",
|
||||
"diff" | "patch" => "diff",
|
||||
"svelte" => "svelte",
|
||||
"astro" => "astro",
|
||||
"scala" => "scala",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||
pub(super) naddr: String,
|
||||
/// Hex ID of the announcement event itself.
|
||||
pub(super) event_id: String,
|
||||
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
|
||||
pub(super) coordinate: String,
|
||||
/// `https://gitworkshop.dev/<naddr>`
|
||||
pub(super) gitworkshop: String,
|
||||
/// `https://ditto.pub/<naddr>`
|
||||
pub(super) ditto: String,
|
||||
}
|
||||
|
||||
impl ShareTargets {
|
||||
pub(super) fn from_announcement(announcement: &Announcement) -> Self {
|
||||
let addr = announcement.addr();
|
||||
let coordinate = addr.to_string();
|
||||
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
|
||||
.to_bech32()
|
||||
.expect("a complete coordinate always encodes to naddr");
|
||||
|
||||
Self {
|
||||
naddr: naddr.clone(),
|
||||
event_id: announcement.event_id.to_bech32().unwrap(),
|
||||
coordinate,
|
||||
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
|
||||
ditto: format!("https://ditto.pub/{naddr}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The share dropdown menu, one row per target.
|
||||
///
|
||||
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
menu.min_w(px(340.))
|
||||
.item(menu_copy_row(
|
||||
"copy-gitworkshop",
|
||||
"GitWorkshop",
|
||||
truncate_naddr_link(&self.gitworkshop, 4),
|
||||
self.gitworkshop.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-ditto",
|
||||
"Ditto",
|
||||
truncate_naddr_link(&self.ditto, 4),
|
||||
self.ditto.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-event-id",
|
||||
"Event ID",
|
||||
middle_truncate(&self.event_id, 10, 10),
|
||||
self.event_id.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-coordinate",
|
||||
"Coordinate",
|
||||
middle_truncate(&self.coordinate, 10, 10),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
|
||||
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||
return url.to_string();
|
||||
};
|
||||
if url.len() - end <= tail + 3 {
|
||||
return url.to_string();
|
||||
}
|
||||
format!("{}...{}", &url[..end], &url[url.len() - tail..])
|
||||
}
|
||||
|
||||
/// Width of one line-number gutter in a diff row.
|
||||
pub(super) const GUTTER_WIDTH: f32 = 44.;
|
||||
/// Height of one row in a virtual diff list.
|
||||
pub(super) 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(super) enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
/// Line `line` of hunk `hunk` of the selected file's diff.
|
||||
Line { hunk: usize, line: usize },
|
||||
}
|
||||
|
||||
/// The rows of `file`'s diff, one header row per hunk then its lines.
|
||||
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// One row of the virtual diff list, a hunk header or a single line.
|
||||
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// One diff line, old and new line numbers in the gutters.
|
||||
///
|
||||
/// The content is tinted by kind, addition, deletion or context.
|
||||
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Find a tree item by id, searching into nested children.
|
||||
pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The root issue events of a repo store, for the shared detail sections.
|
||||
pub(super) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
/// The root pull request events of a repo store, for the shared detail sections.
|
||||
pub(super) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
/// The trigger body of the branch/tag selectors.
|
||||
///
|
||||
/// The kind icon, the selection or placeholder, and the caret.
|
||||
/// `Combobox` replaces its default trigger entirely,
|
||||
/// the only way to show an icon inside it.
|
||||
pub(super) fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Section heading of a detail sidebar, shared by the issue and PR panels.
|
||||
pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar with participants and labels of a root event, issue or PR.
|
||||
pub(super) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comments on a root event, issue or PR, one card per comment.
|
||||
pub(super) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comment form posting to an issue or PR root event.
|
||||
///
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(super) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_nested_tree_from_flat_entries() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||
|
||||
assert_eq!(items[1].label, "README.md");
|
||||
assert_eq!(items[1].id, "README.md");
|
||||
|
||||
assert_eq!(items[2].label, "docs");
|
||||
assert_eq!(items[2].children[0].label, "guide.md");
|
||||
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_handles_deep_nesting() {
|
||||
let entries = vec![
|
||||
PathBuf::from("a"),
|
||||
PathBuf::from("a/b"),
|
||||
PathBuf::from("a/b/c.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].children[0].id, "a/b");
|
||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
PathBuf::from("a/z.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "a");
|
||||
assert_eq!(items[0].children.len(), 2);
|
||||
assert_eq!(items[1].label, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_seeds_convert_to_tree_items() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/main.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
];
|
||||
|
||||
let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_language_maps_extensions_and_names() {
|
||||
assert_eq!(code_language("src/main.rs"), Some("rust"));
|
||||
assert_eq!(code_language("Cargo.toml"), Some("toml"));
|
||||
assert_eq!(code_language("app.js"), Some("javascript"));
|
||||
assert_eq!(code_language("index.tsx"), Some("tsx"));
|
||||
assert_eq!(code_language("Makefile"), Some("make"));
|
||||
assert_eq!(code_language("CMakeLists.txt"), Some("cmake"));
|
||||
assert_eq!(code_language("data.csv"), None);
|
||||
assert_eq!(code_language("LICENSE"), None);
|
||||
assert_eq!(code_language("README.md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naddr_link_keeps_url_and_tail() {
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
|
||||
"https://gitworkshop.dev/naddr1...1234"
|
||||
);
|
||||
// Without the naddr1 prefix, unchanged.
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://example.com/x", 4),
|
||||
"https://example.com/x"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
@@ -5,7 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
@@ -23,21 +24,32 @@ 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,
|
||||
}
|
||||
|
||||
impl AsRef<str> for RepoFilter {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
RepoFilter::All => "all",
|
||||
RepoFilter::Popular => "popular",
|
||||
RepoFilter::Recent => "recent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RepoFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoFilter {
|
||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||
///
|
||||
@@ -49,6 +61,7 @@ impl RepoFilter {
|
||||
// Narrow by the search query first.
|
||||
// Recent then limits the matches and Popular ranks them.
|
||||
let query = query.trim().to_lowercase();
|
||||
|
||||
if !query.is_empty() {
|
||||
indices.retain(|&ix| {
|
||||
let announcement = &announcements[ix];
|
||||
@@ -82,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,
|
||||
}
|
||||
@@ -111,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) {
|
||||
@@ -125,7 +132,11 @@ impl RepoListView {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
focus_handle: cx.focus_handle(),
|
||||
@@ -137,27 +148,19 @@ impl RepoListView {
|
||||
search,
|
||||
_search_subscription: search_subscription,
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
// Seed the rows right away.
|
||||
// The store may already hold announcements from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.rebuild_rows(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let store = self.store.read(cx);
|
||||
|
||||
self.visible = filter.visible(store, &query);
|
||||
|
||||
// Each virtual list row holds `COLUMNS` repo cards.
|
||||
let rows = self.visible.len().div_ceil(COLUMNS);
|
||||
|
||||
if self.repo_len != rows {
|
||||
self.repo_len = rows;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
|
||||
@@ -172,7 +175,13 @@ impl RepoListView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
open_repo_panel(
|
||||
&self.dock_area,
|
||||
&announcement.addr(),
|
||||
Some(announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_card(
|
||||
@@ -308,6 +317,22 @@ impl RepoListView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_filter<T>(&self, filter: RepoFilter, label: T, cx: &mut Context<Self>) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(filter.to_string(), label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -315,18 +340,24 @@ impl RepoListView {
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().font_semibold().child("Repositories"))
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.w_10()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.line_height(relative(1.2))
|
||||
.child("Repositories"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!("({count})"))),
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::from(format!("Total: {count}"))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -342,31 +373,12 @@ impl RepoListView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.filter_button(RepoFilter::All, "All", cx))
|
||||
.child(self.filter_button(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.filter_button(RepoFilter::Recent, "Recent", cx)),
|
||||
.child(self.render_filter(RepoFilter::All, "All", cx))
|
||||
.child(self.render_filter(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.render_filter(RepoFilter::Recent, "Recent", cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One segmented header filter button, like the issues list's status filter buttons.
|
||||
fn filter_button(
|
||||
&self,
|
||||
filter: RepoFilter,
|
||||
label: &'static str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(label, label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for RepoListView {
|
||||
|
||||
-8
@@ -13,19 +13,12 @@ use signed_state::RepoStore;
|
||||
|
||||
pub struct SendPatchView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the panel lives in.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Store of the target repository.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Title input, required.
|
||||
subject: Entity<InputState>,
|
||||
/// Description input, optional.
|
||||
description: Entity<TextareaState>,
|
||||
/// The pasted `git format-patch` output, required.
|
||||
patch: Entity<TextareaState>,
|
||||
/// A submit is in flight.
|
||||
submitting: bool,
|
||||
/// Error of the last submit attempt, it keeps the panel open.
|
||||
error: Option<SharedString>,
|
||||
@@ -61,7 +54,6 @@ impl SendPatchView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the pull request from the pasted patch.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting {
|
||||
return;
|
||||
@@ -17,10 +17,9 @@ use super::super::open_repo_panel;
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
|
||||
/// Open the Create Repository dialog.
|
||||
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
@@ -151,7 +150,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick the repository's storage folder with the platform's native folder picker.
|
||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
@@ -186,8 +184,6 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Run the create-repository flow.
|
||||
///
|
||||
/// Opens the new working copy and the repository panel on success.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_repository(
|
||||
@@ -250,12 +246,17 @@ fn create_repository(
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Open the newly created repository in the dock's center.
|
||||
fn open_repo(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
announcement: Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
open_repo_panel(&dock_area, &announcement, window, cx);
|
||||
open_repo_panel(
|
||||
&dock_area,
|
||||
&announcement.addr(),
|
||||
Some(&announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,16 @@ use signed_state::Backend;
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct GraspServersState {
|
||||
/// The user's grasp list of kind `10317` is being loaded.
|
||||
/// Set while the user's kind `10317` grasp list loads.
|
||||
pub loading_servers: bool,
|
||||
pub grasp_servers: Vec<RelayUrl>,
|
||||
/// Whether the grasp server section is shown. Defaults to shown.
|
||||
pub servers_enabled: bool,
|
||||
/// Error of the last grasp-server edit, an invalid relay URL for example.
|
||||
/// Error from the last grasp-server edit, such as an invalid relay URL.
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl GraspServersState {
|
||||
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
|
||||
/// Defaults used until the user's grasp list loads and replaces them.
|
||||
///
|
||||
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
|
||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||
@@ -137,7 +136,6 @@ pub fn grasp_servers_field(
|
||||
}))
|
||||
}
|
||||
|
||||
/// One grasp server row, the host in a tag plus a remove button.
|
||||
fn render_server_row(
|
||||
ix: usize,
|
||||
relay: &RelayUrl,
|
||||
@@ -176,7 +174,7 @@ fn render_server_row(
|
||||
)
|
||||
}
|
||||
|
||||
/// The bare host of a grasp server, defaults are entered without a scheme.
|
||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
||||
fn display_server(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
@@ -184,7 +182,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
/// Parse the relay input, accepting a bare host, and append it to the list.
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
@@ -220,9 +218,9 @@ fn add_relay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the user's grasp list of kind `10317` from the local database.
|
||||
/// Loads the user's kind `10317` grasp list from the local database.
|
||||
///
|
||||
/// It replaces the defaults when it lists any servers.
|
||||
/// Replaces the defaults when the list is non-empty.
|
||||
pub fn load_user_grasp_servers(
|
||||
state: Entity<GraspServersState>,
|
||||
window: &mut Window,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use gpui::{App, Window, px};
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
/// Open the Import Identity dialog.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
dialog.title("Import identity").width(px(400.))
|
||||
|
||||
@@ -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,14 +157,13 @@ 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).read(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.unpushed(&addr);
|
||||
let count = checkouts.read(cx).unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -202,12 +194,13 @@ impl SidebarPanel {
|
||||
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx));
|
||||
self.inbox = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.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
|
||||
@@ -221,12 +214,13 @@ impl SidebarPanel {
|
||||
let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx));
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.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| {
|
||||
@@ -244,23 +238,25 @@ impl SidebarPanel {
|
||||
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
|
||||
}
|
||||
|
||||
/// Show the Create Repository dialog.
|
||||
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
create_repo_dialog::open(self.dock_area.clone(), window, cx);
|
||||
}
|
||||
|
||||
/// Open a repository's detail view in the dock's center.
|
||||
fn open_repo(
|
||||
&mut self,
|
||||
announcement: &Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
open_repo_panel(
|
||||
&self.dock_area,
|
||||
&announcement.addr(),
|
||||
Some(announcement),
|
||||
window,
|
||||
&mut *cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a local repository's detail view in the dock's center.
|
||||
///
|
||||
/// The detail view offers to publish it to NIP-34.
|
||||
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let detail =
|
||||
@@ -324,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 {
|
||||
@@ -364,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],
|
||||
@@ -393,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())
|
||||
@@ -423,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()
|
||||
@@ -445,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,
|
||||
@@ -480,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()
|
||||
@@ -599,9 +591,9 @@ impl Render for SidebarPanel {
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui_component::tree::TreeItem;
|
||||
|
||||
pub(crate) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
pub(crate) id: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
item = item.expanded(true);
|
||||
}
|
||||
item.children = seed
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect();
|
||||
item
|
||||
}
|
||||
|
||||
seeds
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// Sorted relative paths of a worktree snapshot.
|
||||
///
|
||||
/// Compared against the `worktree_paths` of a repository panel to skip
|
||||
/// rebuilding the explorer when a refresh left the worktree unchanged.
|
||||
pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
|
||||
let mut paths: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_nested_tree_from_flat_entries() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||
|
||||
assert_eq!(items[1].label, "README.md");
|
||||
assert_eq!(items[1].id, "README.md");
|
||||
|
||||
assert_eq!(items[2].label, "docs");
|
||||
assert_eq!(items[2].children[0].label, "guide.md");
|
||||
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_handles_deep_nesting() {
|
||||
let entries = vec![
|
||||
PathBuf::from("a"),
|
||||
PathBuf::from("a/b"),
|
||||
PathBuf::from("a/b/c.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].children[0].id, "a/b");
|
||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
PathBuf::from("a/z.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "a");
|
||||
assert_eq!(items[0].children.len(), 2);
|
||||
assert_eq!(items[1].label, "b");
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,22 @@
|
||||
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, KeyBinding, Render, Subscription, Window, actions, div, px};
|
||||
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||
use gpui_component::{Root, StyledExt, Theme};
|
||||
use gpui_fps::{FpsMonitor, FpsOverlay};
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use crate::views::SidebarPanel;
|
||||
use crate::views::sidebar::passphrase_dialog;
|
||||
|
||||
actions!(workspace, [ToggleMonitor]);
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
fps: Entity<FpsMonitor>,
|
||||
/// Debug HUD, toggled with `cmd-shift-f`.
|
||||
show_fps: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_passphrase_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let fps = cx.new(|cx| FpsMonitor::new(window, cx).continuous(false));
|
||||
cx.bind_keys([KeyBinding::new("cmd-shift-f", ToggleMonitor, None)]);
|
||||
let backend = Backend::global(cx);
|
||||
let settings = SettingsStore::global(cx);
|
||||
|
||||
let dock = cx.new(|cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
@@ -33,20 +27,15 @@ impl Workspace {
|
||||
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
||||
let weak_sidebar = sidebar.downgrade();
|
||||
|
||||
dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
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);
|
||||
}));
|
||||
}
|
||||
|
||||
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
||||
let dock_for_pruning = dock.clone();
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&dock,
|
||||
window,
|
||||
@@ -54,9 +43,9 @@ impl Workspace {
|
||||
if !matches!(event, DockEvent::LayoutChanged) {
|
||||
return;
|
||||
}
|
||||
let dock = dock_for_pruning.clone();
|
||||
let weak = weak_dock.clone();
|
||||
cx.spawn_in(window, async move |_, window| {
|
||||
dock.update_in(window, |area, window, cx| {
|
||||
weak.update_in(window, |area, window, cx| {
|
||||
for placement in [DockPlacement::Bottom, DockPlacement::Right] {
|
||||
if area.is_empty(placement, cx) {
|
||||
area.remove_dock(placement, window, cx);
|
||||
@@ -69,28 +58,34 @@ impl Workspace {
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
||||
let passphrase_subscription =
|
||||
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&backend,
|
||||
window,
|
||||
|_this, _state, event, window, cx| {
|
||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
this.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
// Open the explore panel after the sidebar has been initialized.
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
weak_sidebar
|
||||
.update(cx, |this, cx| {
|
||||
this.open_explore(window, cx);
|
||||
@@ -100,10 +95,7 @@ impl Workspace {
|
||||
|
||||
Self {
|
||||
dock,
|
||||
show_fps: cfg!(debug_assertions),
|
||||
fps,
|
||||
_subscriptions: subscriptions,
|
||||
_passphrase_subscription: passphrase_subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,21 +107,11 @@ impl Render for Workspace {
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.on_action(
|
||||
cx.listener(|this: &mut Self, _ev: &ToggleMonitor, _window, cx| {
|
||||
this.show_fps = !this.show_fps;
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(self.dock.clone())
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(dialog_layer)
|
||||
// On top of everything, so it stays readable while debugging.
|
||||
.when(self.show_fps, |this| this.child(FpsOverlay::new(&self.fps)))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -17,14 +17,11 @@ fn main() {
|
||||
gpui_component::init(cx);
|
||||
theme::init(cx);
|
||||
|
||||
// Load the persisted settings before applying the theme.
|
||||
// Stored appearance and theme settings then take effect at startup.
|
||||
// The persisted settings must load before the theme is applied.
|
||||
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
||||
SettingsStore::set_global(store.clone(), cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
|
||||
// Register the built-in Signed theme, light and dark variants.
|
||||
// The stored appearance then selects the active theme.
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for (name, content) in Assets.themes() {
|
||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||
@@ -72,7 +69,6 @@ fn main() {
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
// Connects relays and restores the session.
|
||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||
signed_state::init(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,973 +0,0 @@
|
||||
# Inbox (home screen) implementation plan
|
||||
|
||||
Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user.
|
||||
|
||||
> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications`
|
||||
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
|
||||
> an account is active, and that home screen is the inbox.
|
||||
|
||||
> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to
|
||||
> group **threads by repository** and to merge notifications with own activity into one row per thread
|
||||
> (see the repository-grouping and thread-merge notes in §7).
|
||||
> `cargo test -p signed_core` (69), `cargo test -p signed_state` (24),
|
||||
> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets`
|
||||
> clean, `cargo check --workspace --all-targets` succeeds.
|
||||
> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1
|
||||
> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the
|
||||
> Phase 4 click-through, and the repository-grouped thread list. The Phase 3 bottom-dock sub-views were
|
||||
> removed before the redesign; their implementation notes in §7 are historical.
|
||||
|
||||
## 1. What the GitWorkshop home screen is
|
||||
|
||||
`Index.tsx`:
|
||||
|
||||
```tsx
|
||||
if (account) return <Dashboard />;
|
||||
return <LandingPage />;
|
||||
```
|
||||
|
||||
`Dashboard.tsx` layout:
|
||||
|
||||
- Desktop: two columns.
|
||||
- **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`.
|
||||
- **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`,
|
||||
`FollowedReposPanel`.
|
||||
- Mobile: a single column in a different order.
|
||||
|
||||
The panel that gives the screen its inbox identity is `NotificationsPanel`:
|
||||
|
||||
- heading **Notifications** with a bell icon and an unread count badge,
|
||||
- a **Mark all read** action and a **View all** link to `/notifications`,
|
||||
- a compact list of the first 5 **non-archived** notification items,
|
||||
- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon).
|
||||
|
||||
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced
|
||||
inline on the home screen. The home screen also shows your own recent activity and your repositories.
|
||||
|
||||
Data hooks:
|
||||
|
||||
| Section | Hook | What it loads |
|
||||
|---|---|---|
|
||||
| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state |
|
||||
| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 |
|
||||
| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you |
|
||||
| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow |
|
||||
| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services |
|
||||
|
||||
## 2. Scope for Signed
|
||||
|
||||
| Priority | Section | Notes |
|
||||
|---|---|---|
|
||||
| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown |
|
||||
| **P1** | Click-through | Open the issue/PR detail panel at the relevant thread root |
|
||||
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
|
||||
| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed |
|
||||
|
||||
Notes:
|
||||
|
||||
- There is **no greeting header**. The screen starts with the inbox panel.
|
||||
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
|
||||
- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead.
|
||||
|
||||
## 3. The Signed screen
|
||||
|
||||
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered
|
||||
card holding a single virtual list. Every row is either a **repository header** or one of that
|
||||
repository's **threads**, newest first. A thread merges the notifications directed at the user with
|
||||
the user's own events in the same root, and shows the root's title plus up to five of its most recent
|
||||
events:
|
||||
The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other
|
||||
repository that has threads. Owned repositories with nothing to show render an
|
||||
empty state ("No activity yet.") under their header, and sort after the ones with activity (newest
|
||||
announcement first). Threads with no repository address fall into a single "Other repository" section.
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------------------+
|
||||
| Inbox (3 unread) [Mark all read] |
|
||||
|-------------------------------------------------------------------------|
|
||||
| [repo] you/repo-a (2) |
|
||||
| [icon] Add retry logic (unread dot) |
|
||||
| [avatar] You opened an issue 3d |
|
||||
| [avatar] alice commented 2d |
|
||||
| [icon] Fix flaky test |
|
||||
| [avatar] You opened a PR 1h |
|
||||
|-------------------------------------------------------------------------|
|
||||
| [repo] you/repo-b |
|
||||
| No activity yet. |
|
||||
|-------------------------------------------------------------------------|
|
||||
| [repo] you/repo-c |
|
||||
| No activity yet. |
|
||||
+-------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
The sections are the repositories that actually have threads, ordered by their
|
||||
newest row. A repository the user owns but that has no items is not shown. Threads with no repository
|
||||
address fall into a single "Other repository" section.
|
||||
|
||||
## 4. Data layer
|
||||
|
||||
### 4.1 `signed_core`: pure logic
|
||||
|
||||
**`filters.rs`** (extend, next to `activity`/`comments_for`):
|
||||
|
||||
```rust
|
||||
/// Kinds that notify a user when they tag them directly.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequestUpdate,
|
||||
COVER_NOTE_KIND,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Comments on our issues/PRs/patches.
|
||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
/// Activity directed at us: comments on our roots, and git events tagging us.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
/// Git activity authored by `me`, for "Continue where you left off".
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
|
||||
.author(me)
|
||||
}
|
||||
```
|
||||
|
||||
`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs
|
||||
(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`).
|
||||
|
||||
Comments authored by `me` are not all git comments, so the activity query needs a post-filter:
|
||||
keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching
|
||||
gitworkshop's `isGitComment`.
|
||||
|
||||
**`inbox.rs`** (new file):
|
||||
|
||||
```rust
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
/// The root event itself, when known locally; drives the row title.
|
||||
pub root_event: Option<Event>,
|
||||
pub root_kind: Option<Kind>,
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the same thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Title of the thread root; falls back to the newest event it has.
|
||||
pub fn title(&self) -> String;
|
||||
/// Kind of the thread root; falls back to the newest event it has.
|
||||
pub fn kind(&self) -> Option<Kind>;
|
||||
pub fn latest_activity(&self) -> Timestamp;
|
||||
/// Up to `limit` most recent events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event>;
|
||||
pub fn is_unread(&self) -> bool;
|
||||
pub fn apply_state(&mut self, state: &InboxReadState);
|
||||
}
|
||||
|
||||
/// The thread root of a notification event, or `None` if it isn't git-related.
|
||||
pub fn notification_root(
|
||||
event: &Event,
|
||||
lookup: &impl Fn(EventId) -> Option<Event>,
|
||||
) -> Option<EventId>;
|
||||
|
||||
/// Group the notifications directed at the user together with the user's own
|
||||
/// events into one item per thread, newest activity first.
|
||||
pub fn group(
|
||||
events: impl IntoIterator<Item = Event>,
|
||||
own: impl IntoIterator<Item = Event>,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &impl Fn(EventId) -> Option<Event>,
|
||||
) -> Vec<InboxItem>;
|
||||
```
|
||||
|
||||
Root resolution, ported from `getNotificationRootId`:
|
||||
|
||||
- issue (1621) / PR (1618): itself
|
||||
- patch (1617): its `e` parent patch, else itself
|
||||
- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`)
|
||||
- PR update (1619): uppercase `E`
|
||||
- statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||
- notification events authored by `me` are dropped; the user's own events are kept in
|
||||
`own_events` instead, never in `events`
|
||||
- `unread_ids` and `archived` are derived from `events` only, so the user's own activity is never
|
||||
unread and a thread with only own events is never archived
|
||||
|
||||
Read/archive state, the compact high-water-mark model:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)] pub read_before: Timestamp,
|
||||
#[serde(default)] pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)] pub archived_before: Timestamp,
|
||||
#[serde(default)] pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
pub fn is_read(&self, event: &Event) -> bool;
|
||||
pub fn is_archived(&self, event: &Event) -> bool;
|
||||
pub fn mark_read(&mut self, event: &Event);
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
|
||||
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
|
||||
}
|
||||
```
|
||||
|
||||
`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first
|
||||
line; reuse it for the home screen rows.
|
||||
|
||||
### 4.2 Persistence: NIP-78 in the local database, never published
|
||||
|
||||
Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event
|
||||
**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device.
|
||||
|
||||
It is signed with a **random keypair**, never the user's signer. The event is local application
|
||||
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
|
||||
the signer type. The `d` tag identifies the owning user, so state does not leak across identities
|
||||
when the signed-in key changes.
|
||||
|
||||
```rust
|
||||
/// d tag identifying the inbox read/archive state event of `me`.
|
||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
/// Newest stored read state for `me`.
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
// No author filter: the signing key is random per save.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(inbox_state_d_tag(me));
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match serde_json::from_str(&event.content) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign with a fresh random key and store locally.
|
||||
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
|
||||
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
|
||||
client.database().save_event(&event).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
A fresh random key is generated on every save, so each save writes a new event rather than
|
||||
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
|
||||
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
|
||||
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
|
||||
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
|
||||
id across saves; that was removed as more derived state than it was worth.
|
||||
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
||||
|
||||
### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
|
||||
|
||||
The inbox is split in two, because the expensive derivation is only needed while the home screen is
|
||||
open.
|
||||
|
||||
**`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is
|
||||
deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78
|
||||
load/save.
|
||||
|
||||
```rust
|
||||
// backend.rs
|
||||
pub struct Backend {
|
||||
...
|
||||
inbox: Entity<Inbox>,
|
||||
}
|
||||
|
||||
// inbox.rs
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn state(&self) -> &InboxReadState;
|
||||
pub fn is_loaded(&self) -> bool;
|
||||
pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
|
||||
pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx);
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx);
|
||||
pub(crate) fn reset(&mut self, cx);
|
||||
}
|
||||
|
||||
// inbox.rs (signed_state)
|
||||
/// One item per thread, notifications and own activity merged.
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, usize), Error>;
|
||||
```
|
||||
|
||||
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
|
||||
state they were computed with, and the refresh coalescing. There is no separate store entity: the
|
||||
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
|
||||
forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header
|
||||
badge only; there is no global count and no sidebar badge.
|
||||
|
||||
```rust
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
threads: Arc<Vec<InboxItem>>, // one row per thread, merged
|
||||
sections: Arc<Vec<InboxSection>>, // grouped by repository
|
||||
rows: Arc<Vec<InboxRow>>, // flattened list
|
||||
unread_count: usize,
|
||||
state: InboxReadState,
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InboxView {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>); // cx.defer(… sync_state)
|
||||
pub fn sync_state(&mut self, cx); // observes the global Inbox
|
||||
pub fn mark_all_read(&mut self, cx);
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
|
||||
fn refresh(&mut self, cx);
|
||||
fn run_refresh(&mut self, cx);
|
||||
fn regroup(&mut self, cx); // re-apply read state
|
||||
fn rebuild(&mut self, cx); // group by repository, seed owned, flatten
|
||||
fn clear(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
The panel owns three subscriptions that carry logic: it observes the global `Inbox`
|
||||
(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes
|
||||
`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no
|
||||
subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks
|
||||
`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back
|
||||
to the global.
|
||||
|
||||
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
|
||||
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
|
||||
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
|
||||
re-exported for the panel's debounce.
|
||||
|
||||
```rust
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
|
||||
```
|
||||
|
||||
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
|
||||
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
|
||||
`signed_state::init` gains no parameters.
|
||||
|
||||
**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count
|
||||
derivation was removed with it). The unread count lives entirely in the panel, which shows it in
|
||||
its header and per repository section. The trade-off is that the count is only current while the
|
||||
panel is open, which is acceptable now that nothing outside it displays one.
|
||||
|
||||
The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`.
|
||||
|
||||
`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for
|
||||
its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
|
||||
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation
|
||||
directly.
|
||||
|
||||
`InboxView::handle_backend_event` refreshes on:
|
||||
|
||||
- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
|
||||
a deletion (`EventDeletion` / `RequestToVanish`).
|
||||
- `Synced` / `Published`.
|
||||
- everything else: ignored.
|
||||
|
||||
**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the
|
||||
three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore)
|
||||
and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or
|
||||
`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend`
|
||||
while `sync_inbox` is mid-update:
|
||||
|
||||
```rust
|
||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||
let me = self.current_user;
|
||||
|
||||
if let Some(me) = me {
|
||||
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||
|
||||
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements_of(&me)
|
||||
.into_iter()
|
||||
.flat_map(|announcement| announcement.relays)
|
||||
.collect();
|
||||
|
||||
if !relays.is_empty() {
|
||||
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||
}
|
||||
}
|
||||
|
||||
let client = self.client.clone();
|
||||
self.inbox.update(cx, |inbox, cx| match me {
|
||||
Some(me) => inbox.activate(me, client, cx),
|
||||
None => inbox.reset(cx),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
|
||||
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
|
||||
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
|
||||
/ `connect_own_repo_relays`.
|
||||
|
||||
**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own
|
||||
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
|
||||
|
||||
```rust
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||
// state = default; state_loaded = false; cx.notify();
|
||||
// spawn load_state(client, me), then set state and state_loaded = true
|
||||
}
|
||||
```
|
||||
|
||||
`reset` performs the same clearing without a state load, and is used on logout.
|
||||
|
||||
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
|
||||
still gated on the signer because the fetch filters need the user's pubkey.
|
||||
|
||||
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
|
||||
`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
|
||||
read, not a wait on the network; see the note below.
|
||||
|
||||
**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
|
||||
|
||||
- `cx.background_spawn`: query the notification filters and the activity filter from
|
||||
`client.database()`.
|
||||
- Query `filters::deletions()`, build `Deletions`, skip deleted events.
|
||||
- Build `HashMap<EventId, Event>` for root walking; group the notification events with
|
||||
`inbox::group`.
|
||||
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
|
||||
their `K` tag is a git kind; sort newest first.
|
||||
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
|
||||
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
|
||||
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the
|
||||
repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`.
|
||||
|
||||
`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the
|
||||
lists, on the first load it runs the initial refresh, and on a state change (a mark action) it
|
||||
re-derives the flags (`InboxItem::apply_state`).
|
||||
|
||||
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
|
||||
so the query that follows them reads the local cache rather than waiting for the relays. That is
|
||||
deliberate offline-first behavior: cached content appears at once on a warm start and with no
|
||||
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
|
||||
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
|
||||
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
|
||||
reviewed and left as-is.
|
||||
|
||||
**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the
|
||||
global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound
|
||||
the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The
|
||||
panel then re-derives and publishes the unread count.
|
||||
|
||||
**Repository names need no new store**: `RepoListStore` already holds every announcement and
|
||||
`repo_name` resolves an address to a display name.
|
||||
|
||||
### 4.4 `Cargo.toml`
|
||||
|
||||
- `signed_core`: add `serde.workspace` for the `InboxReadState` derives.
|
||||
- `signed_state`: add `serde_json.workspace` for the NIP-78 content.
|
||||
|
||||
## 5. UI
|
||||
|
||||
### 5.1 `InboxView` center panel
|
||||
|
||||
`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
|
||||
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one
|
||||
bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single
|
||||
`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a
|
||||
`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height.
|
||||
The list count is reset from `render` whenever the rendered row count changes.
|
||||
|
||||
- **Header**: the unread count badge and **Mark all read**.
|
||||
- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon,
|
||||
the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows
|
||||
under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an
|
||||
unread dot (the subject is semibold while unread). A repository with nothing to show renders
|
||||
"No activity yet."; the panel-level "You're all caught up." empty state appears only when there are
|
||||
no sections at all (no owned repositories and no items).
|
||||
|
||||
No greeting header, and no **My repositories** column - the sidebar already lists the user's
|
||||
repositories.
|
||||
|
||||
### 5.2 Grouping by repository
|
||||
|
||||
The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called
|
||||
from `run_refresh` and `regroup`), never per frame:
|
||||
|
||||
```rust
|
||||
struct InboxSection {
|
||||
address: Option<RepoAddr>, // repository, None for items without one
|
||||
unread: usize, // unread notification groups
|
||||
entries: Vec<InboxEntry>, // newest first
|
||||
latest: Timestamp, // orders the sections
|
||||
}
|
||||
|
||||
enum InboxEntry { // indices into the panel's own lists
|
||||
Notification(usize),
|
||||
Activity(usize),
|
||||
}
|
||||
|
||||
enum InboxRow { // the flattened list
|
||||
Repo(usize),
|
||||
Entry(usize, usize),
|
||||
Empty, // "No activity yet." under an empty section
|
||||
}
|
||||
```
|
||||
|
||||
The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list`
|
||||
closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies.
|
||||
Notification groups carry their repository in `InboxItem::address`; activity events carry it in a
|
||||
`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out.
|
||||
|
||||
`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes
|
||||
`RepoListStore` so a repository that loads after the last refresh still appears (its own empty
|
||||
section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and
|
||||
`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a
|
||||
late announcement still labels its section without re-deriving the grouping.
|
||||
|
||||
### 5.3 Sidebar
|
||||
|
||||
In `views/sidebar/mod.rs`:
|
||||
|
||||
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
|
||||
- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds
|
||||
a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab).
|
||||
`InboxView::new` takes the sidebar's `WeakEntity<DockArea>` so the panel can open a repo for a row.
|
||||
- Point the existing nav item at it:
|
||||
|
||||
```rust
|
||||
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
||||
```
|
||||
|
||||
- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global
|
||||
`Inbox`. The unread count lives in the panel only.
|
||||
|
||||
### 5.4 Click-through (P1)
|
||||
|
||||
The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`,
|
||||
which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than
|
||||
a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`:
|
||||
|
||||
```rust
|
||||
pub(crate) enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch,
|
||||
}
|
||||
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
announcement: &Announcement,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ }
|
||||
```
|
||||
|
||||
- `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls
|
||||
`RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier
|
||||
version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that
|
||||
docked the repository panel too, which surfaced the repository load state (a `not found` error for
|
||||
an announced repo with no local worktree) and left two center tabs. `RepoDetailView::store()` was
|
||||
removed with it.
|
||||
- `views/mod.rs` re-exports `RepoItem` and `open_repo_item`.
|
||||
- `InboxView::open_item` resolves `item.address` to an `Announcement` from `RepoListStore`, and calls
|
||||
`open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder
|
||||
until the store's fetch lands, then re-renders.
|
||||
- The item panel is added to the center group and activated.
|
||||
|
||||
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
|
||||
patch-root click opens nothing. `RepoItem::Patch` carries no id for that reason. A group whose root is
|
||||
not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing.
|
||||
|
||||
## 6. File-by-file change list
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `crates/signed_core/Cargo.toml` | add `serde` |
|
||||
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
|
||||
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem` (root event, notifications, own events), `notification_root`, `group`, `InboxReadState`, tests |
|
||||
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
|
||||
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
||||
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, merge notifications + activity into threads) |
|
||||
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import |
|
||||
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
|
||||
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) |
|
||||
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) |
|
||||
| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread click-through |
|
||||
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` |
|
||||
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring |
|
||||
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item` (builds its own `RepoStore` via the private `repo_store` helper) |
|
||||
|
||||
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
|
||||
activates the `Inbox` child entity at each signer transition.
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.**
|
||||
Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}`
|
||||
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
|
||||
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
|
||||
and `authored_activity` results must pass through `is_git_activity` before display (comments on
|
||||
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
|
||||
end of Phase 0; 68 after the two Phase 1 additions).
|
||||
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
|
||||
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
||||
implementation notes below.
|
||||
3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item.
|
||||
**DONE.** See the implementation notes below.
|
||||
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||
**Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the
|
||||
notes below are historical.
|
||||
5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation
|
||||
notes below.
|
||||
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
|
||||
view.
|
||||
|
||||
Each phase compiles and is usable on its own.
|
||||
|
||||
### Phase 1 implementation notes
|
||||
|
||||
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
|
||||
and two additions to `crates/signed_core/src/inbox.rs`.
|
||||
|
||||
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
|
||||
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
|
||||
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
|
||||
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
|
||||
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
|
||||
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
|
||||
a re-entrant entity access.
|
||||
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
|
||||
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
|
||||
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
|
||||
`SidebarPanel`, but no longer drive the inbox.
|
||||
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
|
||||
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
|
||||
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
|
||||
before the stored state is read would briefly mark everything unread. The first refresh is chained
|
||||
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
|
||||
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
|
||||
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
|
||||
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
|
||||
user the query was started for.
|
||||
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
|
||||
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
|
||||
Both are covered by tests.
|
||||
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
|
||||
rather than a single hop, because a patch series chains through parent patches. Only the notification
|
||||
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
|
||||
not mistaken for a notification.
|
||||
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
|
||||
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
|
||||
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
|
||||
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
|
||||
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
|
||||
re-derives the groups locally so the UI updates immediately, then persists in the background.
|
||||
- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
|
||||
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
|
||||
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
|
||||
either: the newest state event is selected by `created_at`.
|
||||
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
|
||||
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
|
||||
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
|
||||
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
|
||||
|
||||
### Phase 2 implementation notes
|
||||
|
||||
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
|
||||
|
||||
- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
|
||||
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
|
||||
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
|
||||
was later folded into the panel - see the store-merge note below.)
|
||||
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
|
||||
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
|
||||
`gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
|
||||
repositories, so the panel is a single column.
|
||||
- Notification rows read the newest event of each group for the actor, subject and time, and the
|
||||
root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of
|
||||
`RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged.
|
||||
- The **Unread** / **Archived** header buttons are intentionally absent: they need
|
||||
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
|
||||
so the panel is fully usable on its own.
|
||||
- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
|
||||
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
|
||||
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
|
||||
nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the
|
||||
"idle until signer" rule; auto-opening it as the post-login home is a possible follow-up.
|
||||
- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was
|
||||
removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`,
|
||||
`open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports.
|
||||
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
|
||||
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
|
||||
succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass.
|
||||
|
||||
### Architecture refactor (after Phase 2)
|
||||
|
||||
Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran
|
||||
whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to
|
||||
feed it.
|
||||
|
||||
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save
|
||||
and the mark actions.
|
||||
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
|
||||
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
|
||||
- The panel became the client-side owner of the derivation, initially through a panel-scoped
|
||||
`Entity<InboxStore>`.
|
||||
- `signed_core` is unchanged.
|
||||
|
||||
### Store merged into the panel (after Phase 2)
|
||||
|
||||
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
|
||||
|
||||
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
|
||||
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
|
||||
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
|
||||
methods. The two subscriptions call them directly, with no `update` indirection.
|
||||
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
|
||||
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
|
||||
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
|
||||
until the Phase 3 sub-views wire them up.
|
||||
- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace`
|
||||
(7) pass; clippy and `cargo check --workspace --all-targets` are clean.
|
||||
|
||||
Trade-off: the unread count is derived by the panel, so it is only current while the panel is open.
|
||||
(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar
|
||||
badge removed" below.)
|
||||
|
||||
### Phase 3 implementation notes
|
||||
|
||||
> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single
|
||||
> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale.
|
||||
|
||||
Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No
|
||||
store changes.
|
||||
|
||||
- `add_bottom_panel` sits next to `add_center_panel` and wraps
|
||||
`DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open,
|
||||
and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock,
|
||||
so a closed sub-view leaves no strip behind.
|
||||
- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity<InboxView>` (strong; the
|
||||
panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own
|
||||
`ListState`. There is no subscription: it reads the inbox entity during render, which is enough for
|
||||
GPUI to invalidate the window when the inbox notifies.
|
||||
- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title
|
||||
comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of
|
||||
opening a second one.
|
||||
- `InboxView` regained a `dock_area: WeakEntity<DockArea>` (removed with the My-repositories column)
|
||||
and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom
|
||||
dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called
|
||||
as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`.
|
||||
- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone:
|
||||
Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button
|
||||
(`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()`
|
||||
so it does not also trigger the row's mark-read click. Archived rows are display-only; the read
|
||||
state has no un-archive operation.
|
||||
- `notification_row` takes an id `prefix` and returns `Stateful<Div>` rather than `AnyElement`, so
|
||||
callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and
|
||||
the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would
|
||||
otherwise collide on `(str, ix)` ids.
|
||||
- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets`
|
||||
succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7).
|
||||
|
||||
### Phase 4 implementation notes
|
||||
|
||||
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes.
|
||||
|
||||
- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live
|
||||
in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from
|
||||
the announcement (private `repo_store` helper), so only the item panel is docked.
|
||||
- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and
|
||||
a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is
|
||||
not available. `InboxView` already has the window in the list's `on_click`, so it drives the free
|
||||
function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile.
|
||||
- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures
|
||||
from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves
|
||||
`item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the
|
||||
root kind to a `RepoItem`, and calls `open_repo_item`.
|
||||
- Fixed: the first version opened `RepoDetailView` to borrow its store (`RepoDetailView::store()`),
|
||||
which docked the repository panel alongside the item panel and showed its `not found` load error.
|
||||
`open_repo_item` now builds the `RepoStore` itself and `RepoDetailView::store()` is gone.
|
||||
- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read /
|
||||
archive row behaviour is gone with the sub-views.
|
||||
- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so
|
||||
`open_repo_item` returns before doing anything and nothing is docked.
|
||||
- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds,
|
||||
and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||
|
||||
### Repository grouping redesign (after Phase 4)
|
||||
|
||||
Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes.
|
||||
|
||||
The two-card layout (notifications over activity) was replaced by a single repository-grouped list.
|
||||
|
||||
- The panel now derives `sections: Vec<InboxSection>` and a flattened `rows: Vec<InboxRow>` in
|
||||
`rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the
|
||||
`gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no
|
||||
data duplicated between the section list and the source lists.
|
||||
- `InboxSection` groups a repository's non-archived notification groups and the user's own activity,
|
||||
newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the
|
||||
panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the
|
||||
list renders.
|
||||
- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`,
|
||||
so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts
|
||||
after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's
|
||||
repositories load or change.
|
||||
- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`).
|
||||
Items without an address share the "Other repository" section.
|
||||
- `notification_row` / `activity_row` no longer render the repository name - the section header does.
|
||||
That also drops one `RepoListStore` scan per row.
|
||||
- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the
|
||||
`render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still
|
||||
takes an id prefix so rows stay unique within the list.
|
||||
- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state
|
||||
-p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||
|
||||
### Sidebar badge removed (after the repository grouping redesign)
|
||||
|
||||
Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`,
|
||||
`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`.
|
||||
|
||||
An intermediate change made the sidebar badge live by moving the unread count into the global
|
||||
`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge
|
||||
itself, so the global is thin again.
|
||||
|
||||
- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and
|
||||
its observe of the global `Inbox`.
|
||||
- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` /
|
||||
`refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count
|
||||
refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it.
|
||||
- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for
|
||||
the repository headers; `publish_unread_count` stays deleted.
|
||||
- Consequence: the unread count is only current while the panel is open, and there is no unread
|
||||
indication anywhere else in the app.
|
||||
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
|
||||
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
|
||||
`cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||
|
||||
### Threads merged: notifications + activity (after the sidebar badge removal)
|
||||
|
||||
Files: `crates/signed_core/src/inbox.rs`, `crates/signed_state/src/inbox.rs`,
|
||||
`crates/workspace/src/views/inbox.rs`.
|
||||
|
||||
Notifications and own activity were two separate row kinds that could describe the same thread. They
|
||||
are now one item per thread: the notifications directed at the user and the user's own events in that
|
||||
thread live in the same `InboxItem`. A row shows the thread root's title and up to five of the
|
||||
thread's most recent events:
|
||||
|
||||
```
|
||||
[icon] Add retry logic (unread dot)
|
||||
[avatar] You opened an issue · 3d
|
||||
[avatar] alice commented · 2d
|
||||
```
|
||||
|
||||
- `InboxItem` gained `root_event: Option<Event>` and `own_events: Vec<Event>`. `events` keeps only the
|
||||
notifications (others' events); `own_events` holds the user's own. `unread_ids`/`archived` are
|
||||
derived from `events` alone, so own activity is never unread and a thread with only own events is
|
||||
never archived (`apply_state` guards the empty case).
|
||||
- New methods on `InboxItem`: `title()` (root event's subject, falling back to the newest event),
|
||||
`kind()` (root kind, same fallback), and `timeline(limit)` (thread events deduplicated by id,
|
||||
oldest first, always keeping the root event and filling the remaining slots with the most recent
|
||||
others).
|
||||
- `group` now takes both `events` (notifications) and `own` (the user's activity) and merges them on
|
||||
the resolved root. Own events resolve through the same `notification_root`; an unresolved own event
|
||||
becomes its own root. `query_inbox` returns `(Vec<InboxItem>, usize)` - the separate activity list
|
||||
is gone, and `by_id` is extended with the own events so a comment of ours resolves to its thread.
|
||||
- The panel holds `threads: Arc<Vec<InboxItem>>` instead of `notifications` + `activity`. The
|
||||
`InboxEntry` enum, `entry_time`, `repo_address`, `related_activity`, `notification_row`,
|
||||
`activity_row` and `kind_label` are gone. `thread_row` replaces both row kinds and is clickable like
|
||||
the old notification row; `group_sections` now just buckets threads by `item.address`.
|
||||
- `sub_activity_line` is unchanged and still renders `[avatar] [name] [phrase] · [ago]`, with `You`
|
||||
for the signed-in user and `activity_phrase(kind)` for the verb. Rows are variable height
|
||||
(`py_2`), which `gpui::list` auto-measures.
|
||||
- Thread rows in a section are drawn as one stack: `render_entry` passes `first`/`last` within the
|
||||
section (`entry_ix == 0` / `entry_ix + 1 == section.entries.len()`), and `thread_row` rounds the
|
||||
outer edges (`rounded_t` on the first, `rounded_b` on the last, theme radius) and draws a
|
||||
`border_b_1` divider on every row but the last.
|
||||
- Trade-off: the row title is the thread root's, not the newest event's, so a comment thread no longer
|
||||
previews the comment text. That is the point of the merge - the row identifies the thread.
|
||||
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
|
||||
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
|
||||
`cargo test -p signed_core -p signed_state -p workspace` passes (69 / 24 / 7).
|
||||
|
||||
## 8. Validation
|
||||
|
||||
- `cargo test -p signed_core` (69 tests): root resolution, grouping, merging, read-state cutoff, serde
|
||||
round-trip.
|
||||
- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI
|
||||
(state round-trip, grouping helpers).
|
||||
- `cargo test -p workspace` (7 tests): repository-detail helpers.
|
||||
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
|
||||
`cargo check --workspace --all-targets` after each phase.
|
||||
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
|
||||
populates from another identity's issue/comment, the activity list shows your own items, and that no
|
||||
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
|
||||
state is read back from LMDB. Confirm the sidebar has no unread badge.
|
||||
|
||||
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
|
||||
|
||||
- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,`
|
||||
`GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}`
|
||||
- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}`
|
||||
- Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use
|
||||
`Filter::author`/`authors` for authorship. The `notifications` filter relies on this.
|
||||
- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}`
|
||||
- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers
|
||||
- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators
|
||||
- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`;
|
||||
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
|
||||
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
|
||||
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
|
||||
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
|
||||
`client.sync(...)` both persist received events to LMDB and surface them as
|
||||
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
|
||||
This is why the query right after a fetch is a cache read, not a race.
|
||||
Reference in New Issue
Block a user