Compare commits
2
Commits
17de4f6376
...
1496b7afeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1496b7afeb | ||
|
|
1d224218df |
Generated
-8
@@ -291,7 +291,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"log",
|
||||
"rust-embed",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7887,11 +7886,7 @@ dependencies = [
|
||||
"dock",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"gpui_linux",
|
||||
"gpui_macos",
|
||||
"gpui_platform",
|
||||
"gpui_windows",
|
||||
"log",
|
||||
"paths",
|
||||
"reqwest_client",
|
||||
"settings",
|
||||
@@ -7925,13 +7920,11 @@ name = "signed_nostr"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nostr",
|
||||
"nostr-connect",
|
||||
"nostr-gossip-memory",
|
||||
"nostr-lmdb",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"signed_core",
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
@@ -10801,7 +10794,6 @@ version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assets",
|
||||
"chrono",
|
||||
"dock",
|
||||
"futures",
|
||||
"gix",
|
||||
|
||||
@@ -12,9 +12,6 @@ publish = false
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
@@ -37,7 +34,6 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
|
||||
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
smol = "2"
|
||||
futures = "0.3"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
|
||||
@@ -8,7 +8,6 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+17
-6
@@ -1,4 +1,7 @@
|
||||
use gpui::{Pixels, px};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{Context, Pixels, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
@@ -8,19 +11,27 @@ mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
pub use gpui_component::dock::{
|
||||
AnyDrag, BasePanel, BasePanelView, ClosePanel, DockArea, DockAreaState, DockContext, DockEvent,
|
||||
DockLayout, DockPlacement, DockState, DragPanel, DropIndicator, DropPlaceholderBounds,
|
||||
DropTarget, Panel, PanelControl, PanelEvent, PanelHandle, PanelInfo, PanelState, PanelStyle,
|
||||
PanelView, TitleStyle, ToggleZoom, panel_handle, register_panel,
|
||||
BasePanel, DockArea, DockEvent, DockLayout, DockPlacement, Panel, PanelEvent, panel_handle,
|
||||
};
|
||||
|
||||
/// Add an already-wrapped panel handle to the center of `area`.
|
||||
///
|
||||
/// Every panel entry point opens its panel there.
|
||||
pub fn add_center_panel(
|
||||
area: &mut DockArea,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
||||
}
|
||||
|
||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||
|
||||
/// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
|
||||
pub(crate) fn t(key: &'static str) -> &'static str {
|
||||
match key {
|
||||
"Dock.Unnamed" => "Unnamed",
|
||||
"Dock.Close" => "Close",
|
||||
"Dock.Zoom In" => "Zoom In",
|
||||
"Dock.Zoom Out" => "Zoom Out",
|
||||
|
||||
@@ -17,6 +17,7 @@ use gpui_base::dock::{
|
||||
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||
use gpui_component::menu::DropdownMenu as _;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
@@ -24,9 +25,7 @@ use gpui_component::{
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{
|
||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls,
|
||||
};
|
||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||
|
||||
/// The drag preview's size, reported to base for the drop placeholder.
|
||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||
|
||||
@@ -10,6 +10,7 @@ use gpui_base::dock::{
|
||||
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::PanelHandle;
|
||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
@@ -17,8 +18,8 @@ use gpui_component::{
|
||||
};
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::t;
|
||||
use crate::tab_panel::panel_title;
|
||||
use crate::{PanelHandle, t};
|
||||
|
||||
/// How far a resize handle sticks out past the tile's edge.
|
||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
||||
|
||||
+2
-71
@@ -9,9 +9,6 @@ pub const APP_NAME: &str = "Signed";
|
||||
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
|
||||
pub const APP_NAME_LOWERCASE: &str = "signed";
|
||||
|
||||
/// A custom data directory override, set only by [`set_custom_data_dir`].
|
||||
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// The resolved data directory.
|
||||
/// On macOS, this is `~/Library/Application Support/Signed`.
|
||||
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`.
|
||||
@@ -41,31 +38,10 @@ pub fn documents_dir() -> PathBuf {
|
||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Sets a custom directory for all user data, overriding the default data directory.
|
||||
/// Must be called before any other path operation.
|
||||
/// The directory is created when missing and canonicalized to an absolute path.
|
||||
/// # Panics
|
||||
/// Panics when called after [`data_dir`] or [`config_dir`] was initialized.
|
||||
/// Panics when the directory cannot be created or canonicalized.
|
||||
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
|
||||
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
|
||||
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
|
||||
}
|
||||
|
||||
CUSTOM_DATA_DIR.get_or_init(|| {
|
||||
let path = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
|
||||
path.canonicalize()
|
||||
.expect("failed to canonicalize custom data directory")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.join("config")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
if cfg!(target_os = "windows") {
|
||||
dirs::config_dir()
|
||||
.expect("failed to determine RoamingAppData directory")
|
||||
.join(APP_NAME)
|
||||
@@ -85,9 +61,7 @@ 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 let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.clone()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir()
|
||||
.join("Library/Application Support")
|
||||
.join(APP_NAME)
|
||||
@@ -108,43 +82,6 @@ pub fn data_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the cache directory.
|
||||
pub fn cache_dir() -> &'static PathBuf {
|
||||
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
CACHE_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine caches directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") {
|
||||
flatpak_xdg_cache.into()
|
||||
} else {
|
||||
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
|
||||
}
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else {
|
||||
home_dir().join(".cache").join(APP_NAME_LOWERCASE)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the logs directory.
|
||||
pub fn logs_dir() -> &'static PathBuf {
|
||||
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
LOGS_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir().join("Library/Logs").join(APP_NAME)
|
||||
} else {
|
||||
data_dir().join("logs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the nostr database directory, LMDB.
|
||||
pub fn nostr_dir() -> &'static PathBuf {
|
||||
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
@@ -162,9 +99,3 @@ pub fn settings_file() -> &'static PathBuf {
|
||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `keymap.json` file.
|
||||
pub fn keymap_file() -> &'static PathBuf {
|
||||
static KEYMAP_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json"))
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Default for LocalReposSettings {
|
||||
/// recorded when the user clones a repository or picks a folder in the New PR panel.
|
||||
///
|
||||
/// The panel can then prefill the folder later without asking again.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutRecord {
|
||||
/// Local folder of the checkout.
|
||||
@@ -116,16 +116,6 @@ pub struct CheckoutRecord {
|
||||
pub last_used: u64,
|
||||
}
|
||||
|
||||
impl Default for CheckoutRecord {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
addr: String::new(),
|
||||
last_used: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembered local checkouts, see [`CheckoutRecord`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// A NIP-22 comment thread, a top-level comment on the root event,
|
||||
/// nested replies are ordered oldest first at every level.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentThread {
|
||||
/// The thread's top-level comment.
|
||||
pub comment: Event,
|
||||
/// Replies to [`Self::comment`], nested recursively.
|
||||
pub replies: Vec<CommentThread>,
|
||||
}
|
||||
|
||||
/// The direct parent id of a comment, from its NIP-22 lowercase `e` tag.
|
||||
/// `None` when no `e` tag is present.
|
||||
fn comment_parent(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "e")
|
||||
.and_then(Tag::content)
|
||||
.and_then(|id| EventId::parse(id).ok())
|
||||
}
|
||||
|
||||
/// Group the comments on a root issue, patch or PR into NIP-22 threads,
|
||||
/// a comment whose parent is the root starts a thread.
|
||||
///
|
||||
/// Other comments nest under their parent comment.
|
||||
///
|
||||
/// Threads and replies are ordered oldest first,
|
||||
/// replies with a missing parent are made top-level threads, so none are dropped.
|
||||
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||
// Index comments by their parent id.
|
||||
// Comments without a parent tag reply to the root event itself.
|
||||
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||
for comment in comments {
|
||||
let parent = comment_parent(comment).unwrap_or(root.id);
|
||||
children.entry(parent).or_default().push(comment);
|
||||
}
|
||||
for list in children.values_mut() {
|
||||
list.sort_by_key(|event| event.created_at);
|
||||
}
|
||||
|
||||
let mut visited: HashSet<EventId> = HashSet::new();
|
||||
|
||||
fn build(
|
||||
id: EventId,
|
||||
children: &HashMap<EventId, Vec<&Event>>,
|
||||
visited: &mut HashSet<EventId>,
|
||||
) -> Vec<CommentThread> {
|
||||
let Some(list) = children.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut threads = Vec::new();
|
||||
for event in list {
|
||||
// Guards against malformed reply cycles.
|
||||
if visited.insert(event.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: (*event).clone(),
|
||||
replies: build(event.id, children, visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
threads
|
||||
}
|
||||
|
||||
let mut threads = build(root.id, &children, &mut visited);
|
||||
|
||||
// Orphan replies have an unknown parent comment, so they never reach the root tree.
|
||||
// Surface them as top-level threads so they are not dropped.
|
||||
let mut orphans: Vec<&Event> = comments
|
||||
.iter()
|
||||
.filter(|event| !visited.contains(&event.id))
|
||||
.collect();
|
||||
orphans.sort_by_key(|event| event.created_at);
|
||||
for comment in orphans {
|
||||
if visited.insert(comment.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: comment.clone(),
|
||||
replies: build(comment.id, &children, &mut visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
threads
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event {
|
||||
let tags = parent
|
||||
.map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")])
|
||||
.unwrap_or_default();
|
||||
EventBuilder::new(Kind::Comment, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn flatten(threads: &[CommentThread]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for thread in threads {
|
||||
out.push(thread.comment.content.clone());
|
||||
out.extend(flatten(&thread.replies));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nests_replies_under_their_parents() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
let a1 = comment(&keys, Some(&a), "a1", 200);
|
||||
let a2 = comment(&keys, Some(&a), "a2", 300);
|
||||
let b = comment(&keys, Some(&root), "b", 150);
|
||||
|
||||
let threads = comment_threads(&root, &[a2, b, a, a1]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_without_a_parent_tag_attach_to_the_root() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
// Old-style comments carried no `e` tag at all.
|
||||
let orphan = comment(&keys, None, "no parent", 100);
|
||||
|
||||
let threads = comment_threads(&root, &[orphan]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["no parent"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_replies_are_surfaced_as_top_level_threads() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
|
||||
// `missing` is not in the comment set.
|
||||
// Its reply should still show up.
|
||||
let missing = EventBuilder::new(Kind::Comment, "missing")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200);
|
||||
|
||||
let threads = comment_threads(&root, &[a, reply_to_missing]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "reply to missing"]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod comments;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod model;
|
||||
@@ -11,7 +10,6 @@ pub mod status;
|
||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use comments::{CommentThread, comment_threads};
|
||||
pub use deletions::Deletions;
|
||||
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
||||
pub use state::{build_state, parse_state};
|
||||
|
||||
+158
-275
@@ -71,7 +71,7 @@ const SCAN_MAX_DEPTH: usize = 12;
|
||||
/// Directories never descended into during a scan.
|
||||
///
|
||||
/// Dependency caches can be enormous without ever containing user repositories.
|
||||
const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
|
||||
const SCAN_SKIPPED_DIR: &str = "node_modules";
|
||||
|
||||
/// Walk `root` recursively and collect the paths of git repositories below it.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||
@@ -107,7 +107,7 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if name.starts_with('.') || SCAN_SKIPPED_DIRS.contains(&name.as_ref()) {
|
||||
if name.starts_with('.') || name == SCAN_SKIPPED_DIR {
|
||||
continue;
|
||||
}
|
||||
stack.push((entry.path(), depth + 1));
|
||||
@@ -127,24 +127,13 @@ pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
|
||||
bail!("destination {} already exists", path.display());
|
||||
}
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in clone_urls {
|
||||
match clone(url, path) {
|
||||
Ok(repo) => {
|
||||
// The initial clone uses the default refspecs.
|
||||
// Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context("failed to clone from any mirror"),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
try_each_url(clone_urls, "clone", |url| {
|
||||
let repo = clone(url, path)?;
|
||||
// The initial clone uses the default refspecs.
|
||||
// Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||
@@ -199,25 +188,14 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
///
|
||||
/// Unresolvable revisions are errors.
|
||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["merge-base", a, b])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git merge-base`")?;
|
||||
|
||||
match output.status.code() {
|
||||
// Exit 1 means no common ancestor, a valid outcome for a proposal.
|
||||
Some(1) => Ok(None),
|
||||
Some(0) => Ok(Some(
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
|
||||
)),
|
||||
_ => bail!(
|
||||
"git merge-base failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
let repo = open_with_cache(repo_path)?;
|
||||
let a = repo.rev_parse_single(a.as_bytes())?;
|
||||
let b = repo.rev_parse_single(b.as_bytes())?;
|
||||
match repo.merge_base(a, b) {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
// No common ancestor, a valid outcome for a proposal.
|
||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,34 +226,6 @@ pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<S
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
/// Whether `patch` applies to the working tree of `repo_path` without modifying anything.
|
||||
pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("apply")
|
||||
.args(["--check", "--3way", "--whitespace=nowarn", "-"])
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git apply --check`")?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin piped")
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"patch does not apply: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -331,14 +281,7 @@ pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
///
|
||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git rev-parse`")?;
|
||||
let output = git_output(repo_path, &["rev-parse", "HEAD"], "git rev-parse")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
@@ -371,13 +314,41 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
let url = url
|
||||
.strip_prefix("grasp://")
|
||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||
///
|
||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| url.to_owned());
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
/// Run `attempt` against each URL in `urls` until one succeeds.
|
||||
///
|
||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||
/// or `no clone URLs provided` when the list is empty.
|
||||
fn try_each_url<F>(urls: &[String], verb: &str, mut attempt: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str) -> Result<()>,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in urls {
|
||||
match attempt(url) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
let url = transport_url(url);
|
||||
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||
|
||||
let mut prepare = gix::prepare_clone(url, path)?;
|
||||
@@ -435,44 +406,44 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
|
||||
|
||||
/// 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<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(&url)
|
||||
.args(["refs/heads/main:refs/heads/main"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||
///
|
||||
/// This mirrors an initialized repository's whole history.
|
||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||
fn push_refspecs(
|
||||
repo_path: &Path,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
repo_id: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(&url)
|
||||
.args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||
args.push("push");
|
||||
args.push(&url);
|
||||
args.extend_from_slice(refspecs);
|
||||
|
||||
let output = git_output(repo_path, &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
@@ -480,7 +451,6 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -489,14 +459,11 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
|
||||
///
|
||||
/// `None` for a repository without commits.
|
||||
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["rev-list", "--max-parents=0", "HEAD"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git rev-list`")?;
|
||||
let output = git_output(
|
||||
repo_path,
|
||||
&["rev-list", "--max-parents=0", "HEAD"],
|
||||
"git rev-list",
|
||||
)?;
|
||||
|
||||
// An unborn HEAD with no commits yet makes `rev-list` fail.
|
||||
// There is no unique commit to report then.
|
||||
@@ -519,15 +486,8 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
if git_in(repo_path, &["remote", "get-url", "origin"]).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// `git remote add` already configures the default fetch refspec.
|
||||
git_in(repo_path, &["remote", "add", "origin", url])?;
|
||||
git_in(
|
||||
repo_path,
|
||||
&[
|
||||
"config",
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/*:refs/remotes/origin/*",
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -550,38 +510,19 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
///
|
||||
/// Never touches the checked-out refs or the worktree.
|
||||
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
try_each_url(urls, "fetch", |url| {
|
||||
let url = transport_url(url);
|
||||
|
||||
for url in urls {
|
||||
let url = url
|
||||
.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| url.to_owned());
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["fetch"])
|
||||
.arg(&url)
|
||||
.arg(refspec)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git fetch`")?;
|
||||
let output = git_output(repo_path, &["fetch", &url, refspec], "git fetch")?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
bail!(
|
||||
"git fetch from {url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context("failed to fetch from any mirror"),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
|
||||
@@ -592,14 +533,11 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||
// `for-each-ref` patterns match whole path components.
|
||||
// A trailing slash would silently change what is matched.
|
||||
let pattern = prefix.trim_end_matches('/');
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["for-each-ref", "--format=%(refname)", pattern])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git for-each-ref`")?;
|
||||
let output = git_output(
|
||||
repo_path,
|
||||
&["for-each-ref", "--format=%(refname)", pattern],
|
||||
"git for-each-ref",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
@@ -659,14 +597,11 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||
///
|
||||
/// `None` when it has no `origin` yet.
|
||||
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workdir)
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git remote get-url`")?;
|
||||
let output = git_output(
|
||||
workdir,
|
||||
&["remote", "get-url", "origin"],
|
||||
"git remote get-url",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
@@ -717,18 +652,25 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, returning trimmed stdout.
|
||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||
///
|
||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
||||
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
/// `what` names the command in the spawn error.
|
||||
fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git`")?;
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, returning trimmed stdout.
|
||||
///
|
||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
||||
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = git_output(dir, args, "git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
@@ -766,7 +708,8 @@ pub fn sanitize_path_component(id: &str) -> String {
|
||||
|
||||
/// In-memory object cache for history walks, see [`open_with_cache`].
|
||||
///
|
||||
/// Without one, every walk re-decodes the same commit objects from the object database.
|
||||
/// Without one, a walk re-decodes the same commit objects from the object database.
|
||||
/// Sized generously: a walk can cover a large portion of the repository's history.
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||
@@ -855,21 +798,35 @@ pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
/// Open the repository at `workdir` with an in-memory object cache sized for history walks.
|
||||
/// Open the repository at `workdir` with an in-memory object cache.
|
||||
///
|
||||
/// Only history walks use it, they re-decode the same commit objects repeatedly.
|
||||
/// Single-object reads open the repository plain.
|
||||
fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||
let mut repo = gix::open(workdir)?;
|
||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] from a walk commit, with author, message title and shortened id.
|
||||
/// `include_description` controls whether the message body is copied.
|
||||
///
|
||||
/// History lists never display it,
|
||||
/// so skipping it saves an allocation per listed commit.
|
||||
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
|
||||
///
|
||||
/// The diff panel fetches the full commit on demand.
|
||||
fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<FileCommit> {
|
||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
file_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] without the message body, for history lists that never display it.
|
||||
///
|
||||
/// Skipping the body saves an allocation per listed commit.
|
||||
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,
|
||||
) -> Result<FileCommit> {
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
Ok(FileCommit {
|
||||
@@ -961,7 +918,7 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
|
||||
|
||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||
{
|
||||
found.push((rel.clone(), file_commit(&commit, true)?));
|
||||
found.push((rel.clone(), file_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
@@ -1010,7 +967,7 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
let info = info?;
|
||||
total += 1;
|
||||
if commits.len() < MAX_LISTED_COMMITS {
|
||||
commits.push(file_commit(&info.object()?, false)?);
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
Ok(CommitList { total, commits })
|
||||
@@ -1098,7 +1055,7 @@ pub struct CommitDiff {
|
||||
///
|
||||
/// Compared against its first parent, the empty tree for the root commit.
|
||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||
commit_diff(&open_with_cache(workdir)?, id)
|
||||
commit_diff(&gix::open(workdir)?, id)
|
||||
}
|
||||
|
||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
@@ -1116,7 +1073,7 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
///
|
||||
/// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let repo = gix::open(workdir)?;
|
||||
let base_tree = repo
|
||||
.rev_parse_single(base.as_bytes())?
|
||||
.object()?
|
||||
@@ -1151,7 +1108,7 @@ pub fn worktree_commit_range_commits(
|
||||
let mut commits = Vec::new();
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
commits.push(file_commit(&info.object()?, false)?);
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
Ok(commits)
|
||||
}
|
||||
@@ -1631,55 +1588,18 @@ fn take_quoted(input: &str) -> Option<(&str, &str)> {
|
||||
}
|
||||
|
||||
/// Undo git's C-style path quoting, `\NNN` octal escapes, `\"` and `\\`.
|
||||
///
|
||||
/// Delegates to gitoxide's C-style quote implementation, `gix::quote::ansi_c::undo`.
|
||||
/// It expects the surrounding double quotes, which are re-added around the interior.
|
||||
fn unquote_path(path: &str) -> Result<String> {
|
||||
if !path.contains('\\') {
|
||||
return Ok(path.to_owned());
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(path.len());
|
||||
let mut bytes = path.as_bytes();
|
||||
while let Some((&b, rest)) = bytes.split_first() {
|
||||
bytes = rest;
|
||||
if b == b'\\' {
|
||||
match bytes.split_first() {
|
||||
Some((&b'"', rest)) | Some((&b'\\', rest)) => {
|
||||
out.push(b);
|
||||
bytes = rest;
|
||||
}
|
||||
Some((&b'n', rest)) => {
|
||||
out.push(b'\n');
|
||||
bytes = rest;
|
||||
}
|
||||
Some((&b't', rest)) => {
|
||||
out.push(b'\t');
|
||||
bytes = rest;
|
||||
}
|
||||
Some((&d1, rest)) if (b'0'..=b'7').contains(&d1) => {
|
||||
let Some((&d2, rest)) = rest.split_first() else {
|
||||
bail!("malformed octal escape in quoted path");
|
||||
};
|
||||
let Some((&d3, rest)) = rest.split_first() else {
|
||||
bail!("malformed octal escape in quoted path");
|
||||
};
|
||||
if !(b'0'..=b'7').contains(&d2) || !(b'0'..=b'7').contains(&d3) {
|
||||
bail!("malformed octal escape in quoted path");
|
||||
}
|
||||
let code =
|
||||
(d1 - b'0') as u16 * 64 + (d2 - b'0') as u16 * 8 + (d3 - b'0') as u16;
|
||||
if code > u8::MAX as u16 {
|
||||
bail!("octal escape out of range in quoted path");
|
||||
}
|
||||
out.push(code as u8);
|
||||
bytes = rest;
|
||||
}
|
||||
_ => bail!("malformed escape in quoted path"),
|
||||
}
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(out).context("invalid UTF-8 in quoted path")
|
||||
let quoted = format!("\"{path}\"");
|
||||
let (unquoted, _) = gix::quote::ansi_c::undo(gix::bstr::BStr::new(quoted.as_bytes()))
|
||||
.map_err(|e| anyhow::anyhow!("malformed quoted path: {e}"))?;
|
||||
String::from_utf8(unquoted.into_owned().to_vec()).context("invalid UTF-8 in quoted path")
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
@@ -1762,7 +1682,7 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit, true)?))
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
|
||||
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
||||
@@ -1770,11 +1690,11 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
///
|
||||
/// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let repo = gix::open(workdir)?;
|
||||
match repo.rev_parse_single(id.as_bytes()) {
|
||||
Ok(commit_id) => {
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit, true)?))
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
@@ -1804,12 +1724,7 @@ pub fn repo_tags(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||
|
||||
/// Short names of local branches, `refs/heads/*`, sorted alphabetically.
|
||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
repo_branches(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// Short names of tags, `refs/tags/*`, sorted alphabetically.
|
||||
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
||||
repo_tags(&open_with_cache(workdir)?)
|
||||
repo_branches(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||
@@ -1870,7 +1785,7 @@ pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||
|
||||
/// [`repo_ref_state`] for the repository at `workdir`.
|
||||
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
||||
repo_ref_state(&open_with_cache(workdir)?)
|
||||
repo_ref_state(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
@@ -1891,7 +1806,7 @@ pub struct WorktreeSnapshot {
|
||||
///
|
||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let repo = gix::open(workdir)?;
|
||||
let readme_path = find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => worktree_read(&repo, path)?,
|
||||
@@ -2291,38 +2206,6 @@ mod tests {
|
||||
assert!(format_patch_between(&path, "feature", "feature").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_applies_checks_without_modifying_the_tree() {
|
||||
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");
|
||||
|
||||
// A clone of the initial state accepts the series.
|
||||
let clone = dir.path().join("clone");
|
||||
git_run(
|
||||
dir.path(),
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
path.to_str().unwrap(),
|
||||
clone.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
git_run(&clone, &["checkout", "-q", &initial]);
|
||||
assert!(patch_applies(&clone, &patch).is_ok());
|
||||
// The check must not have modified the working tree.
|
||||
assert!(!clone.join("feature.txt").exists());
|
||||
|
||||
// A conflicting file makes the same series fail the check.
|
||||
std::fs::write(clone.join("feature.txt"), "conflicting\n").expect("write");
|
||||
assert!(patch_applies(&clone, &patch).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_commit_ref_pushes_to_the_event_namespace() {
|
||||
// A bare server repository reachable via a `file://` URL.
|
||||
@@ -2977,7 +2860,7 @@ mod tests {
|
||||
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
|
||||
|
||||
assert_eq!(
|
||||
worktree_tags(dir).expect("tags"),
|
||||
repo_tags(&repo).expect("tags"),
|
||||
vec!["v0.9".to_string(), "v1.0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
|
||||
@@ -7,7 +7,6 @@ pub struct Update {
|
||||
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
||||
pub coordinate: Option<Coordinate>,
|
||||
pub author: PublicKey,
|
||||
pub event_id: EventId,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
@@ -19,7 +18,6 @@ impl Update {
|
||||
kind: event.kind,
|
||||
coordinate,
|
||||
author: event.pubkey,
|
||||
event_id: event.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
@@ -45,8 +46,6 @@ pub enum BackendEvent {
|
||||
PassphraseRequired,
|
||||
/// The signer changed on login, logout or account switch.
|
||||
SignerChanged,
|
||||
/// Relay bootstrap finished.
|
||||
Connected,
|
||||
/// A new event was received from a relay and stored in the database.
|
||||
NostrUpdate(Update),
|
||||
/// A negentropy sync completed.
|
||||
@@ -80,7 +79,6 @@ pub struct Backend {
|
||||
client: Client,
|
||||
signer: UniversalSigner,
|
||||
current_user: Option<PublicKey>,
|
||||
connected: bool,
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
passphrase_required: bool,
|
||||
@@ -151,7 +149,6 @@ impl Backend {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
connected: false,
|
||||
sync_progress: None,
|
||||
passphrase_required: false,
|
||||
recent_fetches: HashMap::new(),
|
||||
@@ -163,6 +160,14 @@ impl Backend {
|
||||
this
|
||||
}
|
||||
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Bootstrap the client.
|
||||
///
|
||||
/// Restore the saved session, if any.
|
||||
@@ -183,14 +188,10 @@ impl Backend {
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(()) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.connected = true;
|
||||
cx.emit(BackendEvent::Connected);
|
||||
cx.notify();
|
||||
})?;
|
||||
this.update(cx, |_this, cx| cx.notify())?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
@@ -215,7 +216,7 @@ impl Backend {
|
||||
|
||||
let user = cx.read_credentials(USER_KEYRING);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let content = match user.await {
|
||||
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
||||
_ => {
|
||||
@@ -913,7 +914,7 @@ impl Backend {
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes());
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = write.await {
|
||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
return Ok(());
|
||||
@@ -939,7 +940,7 @@ impl Backend {
|
||||
let credential = with_master_key(&uri_string, &keys);
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let result = async {
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
@@ -971,7 +972,7 @@ impl Backend {
|
||||
pub fn logout(&mut self, cx: &mut Context<Self>) {
|
||||
let delete = cx.delete_credentials(USER_KEYRING);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
delete.await.ok();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -991,24 +992,16 @@ impl Backend {
|
||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let result = async {
|
||||
let events = client.fetch_events(filters::grasp_list(public_key)).await?;
|
||||
|
||||
let urls: Vec<String> = events
|
||||
let events: Vec<Event> = client
|
||||
.fetch_events(filters::grasp_list(public_key))
|
||||
.await?
|
||||
.into_iter()
|
||||
.max_by_key(|e| e.created_at)
|
||||
.map(|e| {
|
||||
e.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind() == "g")
|
||||
.filter_map(|t| t.content().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.collect();
|
||||
|
||||
for url in urls {
|
||||
client.add_relay(&url).await.ok();
|
||||
for url in latest_grasp_list_servers(events) {
|
||||
client.add_relay(url.as_str()).await.ok();
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
@@ -1049,11 +1042,6 @@ impl Backend {
|
||||
cx.emit(BackendEvent::error(message));
|
||||
}
|
||||
|
||||
/// Whether the relay bootstrap has completed.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
|
||||
/// Progress of the in-flight negentropy sync, if any.
|
||||
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
||||
self.sync_progress
|
||||
@@ -1088,7 +1076,7 @@ impl Backend {
|
||||
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
self.push_task(task);
|
||||
}
|
||||
|
||||
/// Add relays and connect to them.
|
||||
@@ -1103,14 +1091,10 @@ impl Backend {
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(()) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.connected = true;
|
||||
cx.emit(BackendEvent::Connected);
|
||||
cx.notify();
|
||||
})?;
|
||||
this.update(cx, |_this, cx| cx.notify())?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
@@ -1120,43 +1104,6 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
|
||||
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
for url in urls {
|
||||
client
|
||||
.add_relay(&url)
|
||||
.capabilities(RelayCapabilities::DISCOVERY)
|
||||
.await?;
|
||||
}
|
||||
client.connect().await;
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a persistent subscription.
|
||||
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
let task = cx.background_spawn(async move { client.subscribe(filter).await.map(|_| ()) });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
|
||||
///
|
||||
/// Records the fingerprint when returning `false`, pruning expired entries first.
|
||||
@@ -1186,7 +1133,7 @@ impl Backend {
|
||||
|
||||
let client = self.client.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
|
||||
log::warn!("repo relay fetch failed: {e}");
|
||||
// Allow an immediate retry after a failure.
|
||||
@@ -1206,7 +1153,7 @@ impl Backend {
|
||||
let task =
|
||||
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
}
|
||||
@@ -1229,7 +1176,7 @@ impl Backend {
|
||||
|
||||
let (tx, mut rx) = SyncProgress::channel();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let mut last_percent: u64 = 0;
|
||||
|
||||
while rx.changed().await.is_ok() {
|
||||
@@ -1262,7 +1209,7 @@ impl Backend {
|
||||
sync_bootstrap_only(&client, filter, opts).await
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(summary) => {
|
||||
log::debug!(
|
||||
@@ -1298,44 +1245,11 @@ impl Backend {
|
||||
let client = self.client.clone();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
self.publish_task(cx, async move {
|
||||
// Sign with the current signer, broadcast and save locally.
|
||||
// The event is immediately visible to database queries.
|
||||
let work = cx.background_spawn(async move {
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
return Err(anyhow!("event not accepted by any relay: {reasons}"));
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
});
|
||||
|
||||
let result = work.await;
|
||||
|
||||
match &result {
|
||||
Ok(event) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(BackendEvent::Published(Box::new(event.clone())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(BackendEvent::error(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
broadcast_event(&client, &event).await
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1346,25 +1260,17 @@ impl Backend {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Event, Error>> {
|
||||
let client = self.client.clone();
|
||||
self.publish_task(cx, async move { broadcast_event(&client, &event).await })
|
||||
}
|
||||
|
||||
/// Run `work` in the background, then emit its outcome as a [`BackendEvent`].
|
||||
fn publish_task(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl Future<Output = Result<Event, Error>> + 'static + Send,
|
||||
) -> Task<Result<Event, Error>> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let work = cx.background_spawn(async move {
|
||||
let output = client.send_event(&event).await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
return Err(anyhow!("event not accepted by any relay: {reasons}"));
|
||||
}
|
||||
|
||||
Ok(event.clone())
|
||||
});
|
||||
|
||||
let result = work.await;
|
||||
let result = cx.background_spawn(work).await;
|
||||
|
||||
match &result {
|
||||
Ok(event) => {
|
||||
@@ -1385,20 +1291,11 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
|
||||
pub fn publish_announcement(
|
||||
&mut self,
|
||||
announcement: GitRepositoryAnnouncement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Event, Error>> {
|
||||
self.send(announcement.into_event_builder(), cx)
|
||||
}
|
||||
|
||||
/// Sign, broadcast and store an event without awaiting the result.
|
||||
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||
let task = self.send(builder, cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(BackendEvent::error(e.to_string()));
|
||||
@@ -1424,7 +1321,7 @@ impl Backend {
|
||||
|
||||
let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
self.push_task(cx.spawn(async move |_this, _cx| {
|
||||
if let Err(e) = task.await {
|
||||
log::warn!("failed to retract repository events: {e}");
|
||||
}
|
||||
@@ -1433,6 +1330,25 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast an event and fail when no relay accepted it.
|
||||
///
|
||||
/// The client stores accepted events locally, visible to database queries.
|
||||
async fn broadcast_event(client: &Client, event: &Event) -> Result<Event, Error> {
|
||||
let output = client.send_event(event).await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
return Err(anyhow!("event not accepted by any relay: {reasons}"));
|
||||
}
|
||||
|
||||
Ok(event.clone())
|
||||
}
|
||||
|
||||
/// Fingerprint of a relay and filter set, for fetch dedup.
|
||||
///
|
||||
/// Relays and filters are sorted first, so the fingerprint is order-independent.
|
||||
@@ -1603,8 +1519,8 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the user's published grasp servers.
|
||||
pub(crate) async fn user_grasp_list_servers(
|
||||
/// Resolve the user's published grasp servers from the local database.
|
||||
pub async fn user_grasp_list_servers(
|
||||
client: Client,
|
||||
user: PublicKey,
|
||||
) -> Result<Vec<RelayUrl>, Error> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -13,6 +12,7 @@ use signed_core::{Announcement, RepoAddr};
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::git_store::GitStore;
|
||||
use crate::local_repos::LocalReposStore;
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation.
|
||||
@@ -66,9 +66,9 @@ struct Remembered {
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository.
|
||||
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||
/// Repositories whose statuses are recomputed on every input change.
|
||||
///
|
||||
/// Those are the repository detail panels currently open.
|
||||
@@ -78,15 +78,13 @@ pub struct CheckoutsStore {
|
||||
/// The sidebar rows of the user's own repositories and their detail panels.
|
||||
push_requested: HashSet<RepoAddr>,
|
||||
/// Ready-to-push statuses of the requested own repositories.
|
||||
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||
/// Last announced head branch per requested repository.
|
||||
///
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -130,23 +128,21 @@ impl CheckoutsStore {
|
||||
this.status_requested.clear();
|
||||
this.push_requested.clear();
|
||||
this.requested_head.clear();
|
||||
this.statuses = Arc::new(HashMap::new());
|
||||
this.push_statuses = Arc::new(HashMap::new());
|
||||
this.statuses = HashMap::new();
|
||||
this.push_statuses = HashMap::new();
|
||||
this.refresh(cx);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut store = Self {
|
||||
by_repo: Arc::new(HashMap::new()),
|
||||
statuses: Arc::new(HashMap::new()),
|
||||
by_repo: HashMap::new(),
|
||||
statuses: HashMap::new(),
|
||||
status_requested: HashSet::new(),
|
||||
push_requested: HashSet::new(),
|
||||
push_statuses: Arc::new(HashMap::new()),
|
||||
push_statuses: HashMap::new(),
|
||||
requested_head: HashMap::new(),
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
refresh: RefreshGate::default(),
|
||||
_subscriptions: subscriptions,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
@@ -158,6 +154,14 @@ impl CheckoutsStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
@@ -236,30 +240,22 @@ impl CheckoutsStore {
|
||||
///
|
||||
/// Requests arriving while a pass runs fold into a follow-up.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
if self.debouncing {
|
||||
return;
|
||||
}
|
||||
self.debouncing = true;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
self.push_task(task);
|
||||
}
|
||||
|
||||
/// One resolve and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
self.refresh.begin();
|
||||
|
||||
// Inputs snapshot, all cheap shared reads.
|
||||
let records = {
|
||||
@@ -356,30 +352,24 @@ impl CheckoutsStore {
|
||||
Ok::<_, Error>((associations, statuses, push_statuses))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let (associations, statuses, push_statuses) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
// Git reads are best-effort, keep the last results.
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
this.refresh.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.by_repo = Arc::new(associations);
|
||||
this.statuses = Arc::new(statuses);
|
||||
this.push_statuses = Arc::new(push_statuses);
|
||||
this.by_repo = associations;
|
||||
this.statuses = statuses;
|
||||
this.push_statuses = push_statuses;
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
if again {
|
||||
@@ -388,8 +378,8 @@ impl CheckoutsStore {
|
||||
|
||||
// Keep the statuses current while any repository panel is open.
|
||||
this.update(cx, |this, cx| {
|
||||
if poll && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
if poll && this.refresh.idle() {
|
||||
this.refresh.debounce();
|
||||
// Open panels get the fast cadence.
|
||||
// Each cycle fetches every watched checkout's remote.
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
@@ -400,13 +390,10 @@ impl CheckoutsStore {
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(delay).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
});
|
||||
|
||||
this.tasks.push(task);
|
||||
this.push_task(task);
|
||||
}
|
||||
})?;
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ pub struct GitStore(GitCache);
|
||||
|
||||
impl GitStore {
|
||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||
///
|
||||
/// Replaces any installed store, [`signed_state::init`] installs an empty one.
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
|
||||
@@ -3,12 +3,13 @@ mod checkouts;
|
||||
mod git_store;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
@@ -18,12 +19,16 @@ pub use profile::{Profile, ProfileStore};
|
||||
pub use repo::RepoStore;
|
||||
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
pub use utils::shorten_pubkey;
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals.
|
||||
/// Call once at startup, before opening any window that uses the stores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
repos_root: impl Into<PathBuf>,
|
||||
scan_paths: Vec<PathBuf>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Backend> {
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
@@ -41,7 +46,8 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
// The local git clone cache, the grasp mirrors.
|
||||
GitStore::set_global(repos_root, cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::{Receiver, RecvTimeoutError, Sender};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
|
||||
use flume::{Receiver, Sender};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
@@ -60,14 +63,6 @@ impl Profile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Message from the fetch task to the main thread.
|
||||
enum Dispatch {
|
||||
/// A batched sync finished.
|
||||
///
|
||||
/// Re-read seen profiles from the database.
|
||||
Synced,
|
||||
}
|
||||
|
||||
/// How long to wait for more requests before firing a batched sync.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -114,23 +109,15 @@ impl ProfileStore {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel.
|
||||
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
||||
let client = backend.read(cx).client();
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client, &dispatch_tx, &receiver).await
|
||||
}));
|
||||
|
||||
// Re-read seen profiles from the database after each batch sync.
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await {
|
||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||
}
|
||||
Ok(())
|
||||
tasks.push(cx.spawn(async move |_this, cx| {
|
||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||
}));
|
||||
|
||||
let mut store = Self {
|
||||
@@ -145,6 +132,14 @@ impl ProfileStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Get a profile.
|
||||
///
|
||||
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
|
||||
@@ -186,7 +181,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -222,7 +217,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profile)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let profile = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -278,7 +273,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -294,28 +289,43 @@ impl ProfileStore {
|
||||
|
||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||
///
|
||||
/// Results are dispatched to the main thread, which re-reads the database.
|
||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
||||
async fn handle_requests(
|
||||
this: WeakEntity<ProfileStore>,
|
||||
client: &Client,
|
||||
dispatch: &Sender<Dispatch>,
|
||||
receiver: &Receiver<PublicKey>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
// Wait for the first request of a batch.
|
||||
match receiver.recv_timeout(BATCH_TIMEOUT) {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Ok(()),
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
};
|
||||
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;
|
||||
while let Ok(public_key) = receiver.recv_deadline(deadline) {
|
||||
batch.insert(public_key);
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||
futures::future::Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
@@ -327,9 +337,7 @@ impl ProfileStore {
|
||||
// Re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
if dispatch.send(Dispatch::Synced).is_err() {
|
||||
log::warn!("profile dispatch channel closed, dropping sync result");
|
||||
}
|
||||
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
|
||||
}
|
||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/// Refresh coalescing shared by the event stores.
|
||||
///
|
||||
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
||||
/// re-query their inputs on a debounce timer with the same policy:
|
||||
/// a request arriving while a run is in flight is folded into a follow-up run,
|
||||
/// a request arriving while the debounce timer is pending is dropped by it.
|
||||
#[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.
|
||||
Schedule,
|
||||
/// A run or pending timer already covers the request.
|
||||
Fold,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Whether no run is in flight and no timer is pending.
|
||||
pub fn idle(&self) -> bool {
|
||||
!self.running && !self.debouncing
|
||||
}
|
||||
|
||||
/// A new refresh request arrived.
|
||||
///
|
||||
/// Folded into a follow-up run while one is in flight, dropped while the
|
||||
/// debounce timer is pending, otherwise starts the timer.
|
||||
pub fn request(&mut self) -> RefreshRequest {
|
||||
if self.running {
|
||||
self.dirty = true;
|
||||
RefreshRequest::Fold
|
||||
} else if self.debouncing {
|
||||
RefreshRequest::Fold
|
||||
} else {
|
||||
self.debouncing = true;
|
||||
RefreshRequest::Schedule
|
||||
}
|
||||
}
|
||||
|
||||
/// A timer was started without a request, e.g. a poll cycle.
|
||||
pub fn debounce(&mut self) {
|
||||
self.debouncing = true;
|
||||
}
|
||||
|
||||
/// The debounce timer fired and the run starts now.
|
||||
pub fn begin(&mut self) {
|
||||
self.debouncing = false;
|
||||
self.running = true;
|
||||
}
|
||||
|
||||
/// The run ended. Whether a request arrived while it ran.
|
||||
pub fn finish(&mut self) -> bool {
|
||||
self.running = false;
|
||||
std::mem::take(&mut self.dirty)
|
||||
}
|
||||
|
||||
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
|
||||
pub fn abort(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
}
|
||||
+15
-166
@@ -9,15 +9,15 @@ use gpui::{AppContext, AsyncApp, Context, SharedString, Subscription, Task, Weak
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{
|
||||
Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note,
|
||||
filters, labels_and_subject, parse_state, pull_request_patch, pull_request_patches,
|
||||
subject_override,
|
||||
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
||||
pull_request_patches,
|
||||
};
|
||||
|
||||
use crate::backend::{
|
||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
|
||||
};
|
||||
use crate::git_store::GitStore;
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
@@ -34,8 +34,6 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||
pub struct RepoStore {
|
||||
addr: RepoAddr,
|
||||
pub announcement: Option<Announcement>,
|
||||
/// `(refname, commit-id)` pairs from the latest state announcement.
|
||||
pub refs: Vec<(String, String)>,
|
||||
/// Branch pointed to by `HEAD` in the latest state announcement.
|
||||
pub head: Option<String>,
|
||||
pub issues: Vec<Event>,
|
||||
@@ -49,11 +47,6 @@ pub struct RepoStore {
|
||||
/// Computed with [`Self::status_by_root`] on every refresh.
|
||||
open_issue_count: usize,
|
||||
open_pr_count: usize,
|
||||
/// Kind-1624 cover notes and kind-1985 label events.
|
||||
///
|
||||
/// They reference this repository's roots, used by ngit and GitWorkshop.
|
||||
cover_notes: Vec<Event>,
|
||||
labels: Vec<Event>,
|
||||
/// Incremented on every applied refresh.
|
||||
///
|
||||
/// Views key their derived-data caches to it instead of recomputing on every render.
|
||||
@@ -71,12 +64,9 @@ pub struct RepoStore {
|
||||
/// Root events, issues, patches and PRs, already fetched per root.
|
||||
///
|
||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||
/// Also kind-1624 cover notes and kind-1985 labels.
|
||||
root_fetches: HashSet<EventId>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
@@ -101,10 +91,8 @@ impl RepoStore {
|
||||
// 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();
|
||||
// Cover notes and labels carry no `a` tag either.
|
||||
let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label;
|
||||
|
||||
deletion || coordinate || (author && kind) || comment || status || annotation
|
||||
deletion || coordinate || (author && kind) || comment || status
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
@@ -128,7 +116,6 @@ impl RepoStore {
|
||||
let mut store = Self {
|
||||
addr,
|
||||
announcement: None,
|
||||
refs: Vec::new(),
|
||||
head: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
@@ -137,16 +124,12 @@ impl RepoStore {
|
||||
status_by_root: HashMap::new(),
|
||||
open_issue_count: 0,
|
||||
open_pr_count: 0,
|
||||
cover_notes: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
version: 0,
|
||||
last_error: None,
|
||||
last_warning: None,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
@@ -225,22 +208,14 @@ impl RepoStore {
|
||||
|
||||
/// Re-query the local database and update all fields.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
if self.debouncing {
|
||||
return;
|
||||
}
|
||||
self.debouncing = true;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
});
|
||||
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
@@ -248,7 +223,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
@@ -282,7 +257,6 @@ impl RepoStore {
|
||||
|
||||
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
|
||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut cover_notes, mut labels): (Vec<Event>, Vec<Event>) = (Vec::new(), Vec::new());
|
||||
|
||||
for event in activity {
|
||||
if deletions.is_deleted(&event) {
|
||||
@@ -338,35 +312,11 @@ impl RepoStore {
|
||||
|
||||
// Cover notes, 1624, and label events, 1985, carry no `a` tag.
|
||||
// Query them per root like comments and statuses.
|
||||
let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect();
|
||||
let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
|
||||
let roots = issues
|
||||
.iter()
|
||||
.chain(&patches)
|
||||
.chain(&pull_requests)
|
||||
.map(|e| e.id);
|
||||
|
||||
for root in roots {
|
||||
for event in db.query(filters::annotations_for([root])).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
if event.kind == COVER_NOTE_KIND && seen_cover_notes.insert(event.id) {
|
||||
cover_notes.push(event);
|
||||
} else if event.kind == Kind::Label && seen_labels.insert(event.id) {
|
||||
labels.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The events are only stored for interop and nothing displays them.
|
||||
sort_newest_first(&mut issues);
|
||||
sort_newest_first(&mut patches);
|
||||
sort_newest_first(&mut pull_requests);
|
||||
sort_oldest_first(&mut comments);
|
||||
sort_newest_first(&mut cover_notes);
|
||||
sort_newest_first(&mut labels);
|
||||
|
||||
// Resolve every root's status once here.
|
||||
// Render paths do HashMap lookups instead of per-root status scans.
|
||||
@@ -402,8 +352,6 @@ impl RepoStore {
|
||||
open_issue_count,
|
||||
open_pr_count,
|
||||
comments,
|
||||
cover_notes,
|
||||
labels,
|
||||
))
|
||||
});
|
||||
|
||||
@@ -420,13 +368,11 @@ impl RepoStore {
|
||||
open_issue_count,
|
||||
open_pr_count,
|
||||
comments,
|
||||
cover_notes,
|
||||
labels,
|
||||
) = match work.await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
return this.update(cx, |this, cx| {
|
||||
this.refreshing = false;
|
||||
this.refresh.abort();
|
||||
this.last_error = Some(e.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
@@ -445,8 +391,7 @@ impl RepoStore {
|
||||
.unwrap_or_default();
|
||||
this.connect_announced_relays(&relays, cx);
|
||||
|
||||
if let Some((refs, head)) = state {
|
||||
this.refs = refs;
|
||||
if let Some((_, head)) = state {
|
||||
this.head = head;
|
||||
}
|
||||
|
||||
@@ -457,11 +402,9 @@ impl RepoStore {
|
||||
this.status_by_root = status_by_root;
|
||||
this.open_issue_count = open_issue_count;
|
||||
this.open_pr_count = open_pr_count;
|
||||
this.cover_notes = cover_notes;
|
||||
this.labels = labels;
|
||||
this.version = this.version.wrapping_add(1);
|
||||
|
||||
// Comments, statuses without an `a` tag, cover notes and labels.
|
||||
// Comments and statuses without an `a` tag.
|
||||
// None are addressed to the repository.
|
||||
// Fetch them by the root events they reference.
|
||||
// Use the bootstrap relays and the relays this repository announced.
|
||||
@@ -482,11 +425,9 @@ impl RepoStore {
|
||||
if !new_roots.is_empty() {
|
||||
this.root_fetches.extend(new_roots.iter().copied());
|
||||
// Batch the per-root filters.
|
||||
// One statuses filter and one annotations filter cover all new roots.
|
||||
// One filter per root costs a negentropy reconciliation per relay.
|
||||
let mut root_filters = filters::comments_for(new_roots.clone());
|
||||
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
||||
root_filters.push(filters::annotations_for(new_roots));
|
||||
|
||||
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||
let backend = Backend::global(cx);
|
||||
@@ -498,13 +439,7 @@ impl RepoStore {
|
||||
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running.
|
||||
@@ -528,40 +463,6 @@ impl RepoStore {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// The effective cover note of `root`, kind 1624, if any.
|
||||
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
|
||||
cover_note(root, &self.cover_notes, &maintainers)
|
||||
}
|
||||
|
||||
/// The effective hashtag labels of `root`.
|
||||
pub fn labels_of(&self, root: &Event) -> Vec<String> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (labels, _) = labels_and_subject(root, &self.labels, &maintainers);
|
||||
labels
|
||||
}
|
||||
|
||||
/// The effective subject or title override of `root`, if any.
|
||||
pub fn subject_of(&self, root: &Event) -> Option<String> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
|
||||
subject_override(root, &self.labels, &maintainers)
|
||||
}
|
||||
|
||||
/// Number of open issues.
|
||||
///
|
||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||
@@ -1067,58 +968,6 @@ impl RepoStore {
|
||||
self.send(builder, cx);
|
||||
}
|
||||
|
||||
/// Publish a repository state announcement
|
||||
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.last_error = Some("Sign in to publish repository state".into());
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
if !self.is_author(&user) {
|
||||
self.last_error = Some("Only the repository owner can publish state".into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = self.addr.clone();
|
||||
let clone_urls: Vec<String> = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
signed_git::repo_ref_state(&repo)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let state = match work.await {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
return this.update(cx, |this, cx| {
|
||||
this.last_error = Some(e.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let builder =
|
||||
build_state(&this.addr.identifier, &state.refs, state.head.as_deref());
|
||||
this.send(builder, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Merge a pull request.
|
||||
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
@@ -8,6 +8,7 @@ use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
///
|
||||
@@ -53,10 +54,8 @@ pub struct RepoListStore {
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
@@ -118,9 +117,7 @@ impl RepoListStore {
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
author,
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
@@ -132,11 +129,12 @@ impl RepoListStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Scope the list to an author, or clear the scope with `None`.
|
||||
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.author = author;
|
||||
self.subscribe_remote(cx);
|
||||
self.refresh(cx);
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
@@ -160,9 +158,9 @@ impl RepoListStore {
|
||||
/// 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.debouncing);
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
@@ -170,30 +168,22 @@ impl RepoListStore {
|
||||
|
||||
/// Re-query the local database.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
if self.debouncing {
|
||||
return;
|
||||
}
|
||||
self.debouncing = true;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
self.push_task(task);
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
@@ -309,13 +299,13 @@ impl RepoListStore {
|
||||
Ok::<_, Error>((announcements, last_activity, counts))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity, counts) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient, keep the last list.
|
||||
Err(_) => {
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
this.refresh.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -326,13 +316,7 @@ impl RepoListStore {
|
||||
this.counts = Arc::new(counts);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running.
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::mem::take;
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||
};
|
||||
|
||||
/// Default number of images each view's cache retains.
|
||||
/// Loading a new image evicts the least recently used entry once this is reached.
|
||||
pub const MAX_IMAGES: usize = 128;
|
||||
|
||||
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider {
|
||||
AppImageCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppImageCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for AppImageCacheProvider {
|
||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |id, window| {
|
||||
window.with_element_state(id, |cache, _| {
|
||||
let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx));
|
||||
(cache.clone(), cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppImageCache {
|
||||
max_items: usize,
|
||||
usage_list: VecDeque<u64>,
|
||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||
}
|
||||
|
||||
impl AppImageCache {
|
||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
log::info!("Creating AppImageCacheProvider");
|
||||
cx.on_release(|this: &mut Self, cx| {
|
||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
log::info!("Dropping image {ix}");
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
ImageSource::Resource(resource).remove_asset(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
AppImageCache {
|
||||
max_items,
|
||||
usage_list: VecDeque::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for AppImageCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &Resource,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_idx = self
|
||||
.usage_list
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache has an item usage_list doesn't");
|
||||
|
||||
self.usage_list.remove(current_idx);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
return item.0.get();
|
||||
}
|
||||
|
||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(load_future).shared();
|
||||
|
||||
if self.usage_list.len() >= self.max_items {
|
||||
log::info!("Image cache is full, evicting oldest item");
|
||||
|
||||
if let Some(oldest) = self.usage_list.pop_back() {
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("usage_list has an item cache doesn't");
|
||||
|
||||
if let Some(Ok(image)) = image.0.get() {
|
||||
log::info!("requesting image to be dropped");
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
|
||||
ImageSource::Resource(image.1).remove_asset(cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.cache.insert(
|
||||
hash,
|
||||
(
|
||||
gpui::ImageCacheItem::Loading(task.clone()),
|
||||
resource.clone(),
|
||||
),
|
||||
);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("error loading image into cache: {:?}", err);
|
||||
}
|
||||
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,10 @@ mod tree_row;
|
||||
mod user_avatar;
|
||||
|
||||
pub mod copy_row;
|
||||
pub mod image_cache;
|
||||
pub mod util;
|
||||
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use image_cache::{MAX_IMAGES, image_cache};
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
|
||||
@@ -14,6 +14,9 @@ const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Each left-half cell is mirrored to a right-half one.
|
||||
const MIN_FILLED: usize = 5;
|
||||
|
||||
/// Side length of the avatar in pixels, no setter.
|
||||
const AVATAR_SIZE: Pixels = px(16.);
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
/// An 8×8 grid with horizontal mirror symmetry.
|
||||
/// Seeded from a stable string such as the repository id and owner public key.
|
||||
@@ -21,7 +24,6 @@ const MIN_FILLED: usize = 5;
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
@@ -31,7 +33,6 @@ impl PixelAvatar {
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: px(16.),
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
@@ -79,7 +80,7 @@ impl RenderOnce for PixelAvatar {
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(self.size)
|
||||
.size(AVATAR_SIZE)
|
||||
.flex_shrink_0()
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
|
||||
@@ -23,6 +23,5 @@ gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -3,7 +3,6 @@ mod workspace;
|
||||
|
||||
use gpui::{App, AppContext, Entity, Window};
|
||||
use gpui_component::Root;
|
||||
pub use signed_ui::image_cache;
|
||||
pub use views::{RepoListView, SidebarPanel};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
/// An action started, disable the form and clear the previous error.
|
||||
pub fn begin(&mut self) {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// An action failed, re-enable the form and surface `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
self.error = Some(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared error line under a dialog form, `None` when there is no error.
|
||||
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||
error.as_ref().map(|message| {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(message.clone())
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod dialog_state;
|
||||
mod repo_detail;
|
||||
mod repo_list;
|
||||
pub(crate) mod sidebar;
|
||||
|
||||
@@ -325,7 +325,7 @@ pub struct CommitDiffView {
|
||||
error: Option<SharedString>,
|
||||
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||
pane: Entity<DiffPane>,
|
||||
/// In-flight tasks, pruned on every push, see [`helpers::track`].
|
||||
/// In-flight tasks, pruned on every push.
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div, px};
|
||||
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, h_flex};
|
||||
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_ui::{menu_copy_row, middle_truncate};
|
||||
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.
|
||||
@@ -348,6 +357,257 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&
|
||||
})
|
||||
}
|
||||
|
||||
/// The root issue events of a repo store, for the shared detail sections.
|
||||
pub(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::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui::{App, Entity, WeakEntity, Window, px};
|
||||
use gpui_base::h_flex;
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
@@ -14,16 +14,13 @@ use settings::SettingsStore;
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
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.
|
||||
#[derive(Default)]
|
||||
pub struct InitRepoState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
pub type InitRepoState = DialogProgress;
|
||||
|
||||
/// Open the Init dialog for the local repository at `local_path`.
|
||||
pub fn open(
|
||||
@@ -113,9 +110,7 @@ pub fn open(
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("init")
|
||||
@@ -169,23 +164,16 @@ fn init_repository(
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Repository name is required".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Add at least one grasp server".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
@@ -210,10 +198,7 @@ fn init_repository(
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||
Window, div, px, relative,
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
|
||||
relative,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::activity_subject;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{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.
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
contents: HashMap<EventId, SharedString>,
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
focus_handle: FocusHandle,
|
||||
@@ -45,192 +40,8 @@ impl IssueDetailView {
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
contents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
|
||||
// `render` already bails out when the issue is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants, the issue author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![issue.pubkey];
|
||||
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Issue labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = issue.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()
|
||||
.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()
|
||||
}
|
||||
|
||||
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(id).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);
|
||||
// Comment bodies become shared strings once per comment, not per render.
|
||||
let content = self
|
||||
.contents
|
||||
.entry(comment.id)
|
||||
.or_insert_with(|| SharedString::from(comment.content.clone()))
|
||||
.clone();
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let comment_input = self.comment_input.clone();
|
||||
let store = self.store.clone();
|
||||
let id = id.to_owned();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&self.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("comment")
|
||||
.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) = store
|
||||
.read(cx)
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == id)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for IssueDetailView {
|
||||
@@ -276,17 +87,11 @@ impl Render for IssueDetailView {
|
||||
let (title, author, picture, status, age, issue_id, content) = {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let profile = profile_store.read(cx).get(&issue.pubkey);
|
||||
let content = self
|
||||
.contents
|
||||
.entry(issue.id)
|
||||
.or_insert_with(|| {
|
||||
if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
}
|
||||
})
|
||||
.clone();
|
||||
let content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
};
|
||||
|
||||
(
|
||||
activity_subject(issue),
|
||||
@@ -300,7 +105,7 @@ impl Render for IssueDetailView {
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.image_cache(image_cache("issue-detail", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("issue-detail"))
|
||||
.id("issue-detail")
|
||||
.size_full()
|
||||
.child(
|
||||
@@ -357,20 +162,24 @@ impl Render for IssueDetailView {
|
||||
)
|
||||
.child(div().text_sm().child(content)),
|
||||
)
|
||||
.child(self.render_comments(&issue_id, cx))
|
||||
.child(self.render_form(&issue_id, cx)),
|
||||
.child(comments_section(&self.store, issue_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
&self.comment_input,
|
||||
"comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(self.render_sidebar(cx))
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
false,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
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,
|
||||
@@ -18,7 +18,6 @@ use gpui_component::{
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -111,7 +110,7 @@ impl IssuesView {
|
||||
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,7 +362,7 @@ impl Render for IssuesView {
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(image_cache("issues", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("issues"))
|
||||
.child(self.render_header(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gix::Repository;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
@@ -16,9 +16,7 @@ use gpui::{
|
||||
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{
|
||||
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
|
||||
};
|
||||
use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState};
|
||||
use gpui_component::menu::DropdownMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tree::TreeState;
|
||||
@@ -26,15 +24,14 @@ use gpui_component::{
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
|
||||
VirtualListScrollHandle, h_flex, v_flex,
|
||||
};
|
||||
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
||||
use nostr::prelude::{RelayUrl, ToBech32};
|
||||
use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
|
||||
use signed_git::{CommitList, FileCommit};
|
||||
use signed_state::{
|
||||
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
|
||||
RepoListStore, RepoStore, pr_proposes_checkout,
|
||||
};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||
use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||
|
||||
mod about;
|
||||
mod browser;
|
||||
@@ -56,7 +53,10 @@ use browser::{
|
||||
};
|
||||
use commits::COMMIT_ROW_HEIGHT;
|
||||
use diff::CommitDiffView;
|
||||
use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items};
|
||||
use helpers::{
|
||||
ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger,
|
||||
tree_items,
|
||||
};
|
||||
use issues::{IssuesView, open_new_issue_dialog};
|
||||
use pull_requests::PullRequestsView;
|
||||
use send_patch::open_send_patch_panel;
|
||||
@@ -107,21 +107,6 @@ struct RepoData {
|
||||
head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
/// Derived NIP-34 header data.
|
||||
/// Renders avoid re-encoding bech32 share targets per frame.
|
||||
/// They also avoid rebuilding the clone command strings.
|
||||
struct HeaderCache {
|
||||
/// Announcement event ID and owner NIP-05 this cache was built from.
|
||||
/// Rebuilt when either changes.
|
||||
/// A new announcement version, or the owner's profile arriving with a NIP-05 identifier.
|
||||
key: (EventId, Option<String>),
|
||||
announcement: Rc<Announcement>,
|
||||
share: Rc<ShareTargets>,
|
||||
ngit_command: SharedString,
|
||||
nak_command: SharedString,
|
||||
git_commands: Rc<Vec<SharedString>>,
|
||||
}
|
||||
|
||||
/// Detail view of a repository, header, stats and metadata.
|
||||
/// A file explorer with README preview, cloned from the announcement's `clone` URLs.
|
||||
pub struct RepoDetailView {
|
||||
@@ -194,10 +179,6 @@ pub struct RepoDetailView {
|
||||
/// Bumped on every branch/tag switch.
|
||||
/// In-flight loads with an older generation are discarded when they complete.
|
||||
ref_generation: u64,
|
||||
/// Derived NIP-34 header data, share targets and clone commands.
|
||||
/// Rebuilt only when the announcement or the owner's NIP-05 changes.
|
||||
/// Not on every render.
|
||||
header_cache: Option<HeaderCache>,
|
||||
/// In-flight tasks, finished tasks are pruned on every push.
|
||||
/// The vec stays bounded by the number of concurrent loads.
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -347,7 +328,6 @@ impl RepoDetailView {
|
||||
tag_select,
|
||||
switching_ref: false,
|
||||
ref_generation: 0,
|
||||
header_cache: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
@@ -923,7 +903,7 @@ impl RepoDetailView {
|
||||
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1057,7 +1037,7 @@ impl RepoDetailView {
|
||||
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1073,7 +1053,7 @@ impl RepoDetailView {
|
||||
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1233,43 +1213,6 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// Trigger body for the branch/tag selectors.
|
||||
/// The kind icon, the selection or placeholder, and the caret.
|
||||
/// `Combobox` replaces its default trigger entirely.
|
||||
/// That is the only way to show an icon inside it.
|
||||
fn render_ref_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()
|
||||
}
|
||||
|
||||
/// Refresh the file explorer, preview pane and commit list after a successful switch.
|
||||
/// The selectors were already updated by [`Self::switch_ref`].
|
||||
/// [`Self::switching_ref`] stays set until this reload finishes.
|
||||
@@ -1423,9 +1366,8 @@ impl RepoDetailView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// The header derives bech32 share targets and clone commands.
|
||||
// Rebuild them only when the announcement or the owner's NIP-05 changes.
|
||||
// Not on every render.
|
||||
// Derived NIP-34 header data, share targets and clone commands.
|
||||
// Rebuilt per frame: two bech32 encodes and a couple of format strings.
|
||||
let nip05 = ProfileStore::global(cx)
|
||||
.read(cx)
|
||||
.get(&source.owner)
|
||||
@@ -1433,32 +1375,12 @@ impl RepoDetailView {
|
||||
.nip05
|
||||
.clone()
|
||||
.filter(|nip05| !nip05.trim().is_empty());
|
||||
let key = (source.event_id, nip05);
|
||||
|
||||
if self
|
||||
.header_cache
|
||||
.as_ref()
|
||||
.is_none_or(|cache| cache.key != key)
|
||||
{
|
||||
let announcement = source.clone();
|
||||
let share = ShareTargets::from_announcement(&announcement);
|
||||
let nostr_url = nostr_clone_url(&announcement, key.1.as_deref());
|
||||
self.header_cache = Some(HeaderCache {
|
||||
ngit_command: SharedString::from(format!("git clone {nostr_url}")),
|
||||
nak_command: SharedString::from(format!("nak git clone {nostr_url}")),
|
||||
git_commands: Rc::new(announcement.clone_urls()),
|
||||
share: Rc::new(share),
|
||||
announcement: Rc::new(announcement),
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
let cache = self.header_cache.as_ref().expect("cache just built");
|
||||
let announcement = cache.announcement.clone();
|
||||
let share = cache.share.clone();
|
||||
let ngit_command = cache.ngit_command.clone();
|
||||
let nak_command = cache.nak_command.clone();
|
||||
let git_commands = cache.git_commands.clone();
|
||||
let announcement = Rc::new(source.clone());
|
||||
let share = Rc::new(ShareTargets::from_announcement(&announcement));
|
||||
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
|
||||
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
|
||||
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
|
||||
let git_commands = Rc::new(announcement.clone_urls());
|
||||
|
||||
let name = self.display_name(cx);
|
||||
let description = announcement.description();
|
||||
@@ -2166,19 +2088,7 @@ impl RepoDetailView {
|
||||
.child("Commits"),
|
||||
)
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
this.child(CountBadge::new(count))
|
||||
})
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
@@ -2242,7 +2152,7 @@ impl RepoDetailView {
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -2256,7 +2166,7 @@ impl RepoDetailView {
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
|
||||
ref_selector_trigger(ctx, CustomIconName::Tag, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -2345,7 +2255,7 @@ impl Render for RepoDetailView {
|
||||
.or_else(|| self.render_push_banner(cx));
|
||||
|
||||
v_flex()
|
||||
.image_cache(image_cache("repo", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("repo"))
|
||||
.id("repo")
|
||||
.size_full()
|
||||
.child(self.render_header(cx))
|
||||
@@ -2493,13 +2403,7 @@ pub(crate) fn open_repo_panel(
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(
|
||||
panel_handle(detail.clone()),
|
||||
DockPlacement::Center,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
add_center_panel(dock_area, panel_handle(detail.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
|
||||
@@ -11,9 +11,7 @@ use gpui::{
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{
|
||||
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
|
||||
};
|
||||
use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState};
|
||||
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_component::menu::{DropdownMenu, PopupMenu, PopupMenuItem};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
@@ -30,10 +28,11 @@ use signed_git::{
|
||||
sanitize_path_component, worktree_commit_range_commits, worktree_commit_range_diff,
|
||||
};
|
||||
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
|
||||
use signed_ui::placeholder;
|
||||
use signed_ui::{CountBadge, placeholder};
|
||||
|
||||
use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::ref_selector_trigger;
|
||||
|
||||
/// The new pull request panel of a repository.
|
||||
pub struct NewPullRequestView {
|
||||
@@ -1019,7 +1018,7 @@ impl NewPullRequestView {
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1080,7 +1079,7 @@ impl NewPullRequestView {
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -1105,7 +1104,7 @@ impl NewPullRequestView {
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -1259,7 +1258,7 @@ impl NewPullRequestView {
|
||||
.child(Icon::new(CustomIconName::GitFile).small())
|
||||
.child("Files"),
|
||||
)
|
||||
.child(count_badge(files, cx))
|
||||
.child(CountBadge::new(files))
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
@@ -1287,7 +1286,7 @@ impl NewPullRequestView {
|
||||
.child(Icon::new(CustomIconName::GitCommit).small())
|
||||
.child("Commits"),
|
||||
)
|
||||
.child(count_badge(commits, cx))
|
||||
.child(CountBadge::new(commits))
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
@@ -1390,55 +1389,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The count badge of a tab, styled like the repository panel's.
|
||||
fn count_badge(count: usize, cx: &App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(count.to_string()))
|
||||
}
|
||||
|
||||
/// The trigger of a branch selector.
|
||||
fn render_ref_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()
|
||||
}
|
||||
|
||||
/// Open the new pull request panel in the center dock.
|
||||
pub(super) fn open_new_pull_panel(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -1449,7 +1399,7 @@ pub(super) fn open_new_pull_panel(
|
||||
let panel = cx.new(|cx| NewPullRequestView::new(dock_area.clone(), store, window, cx));
|
||||
|
||||
let _ = dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -7,38 +6,29 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
ScrollStrategy, SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
||||
SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tab::{Tab, TabBar};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey};
|
||||
use nostr::prelude::{Event, EventId, Kind, Nip34Tag};
|
||||
use signed_core::{activity_subject, pull_request_patch};
|
||||
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
|
||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge, tree_row};
|
||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::CommitDiffView;
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
|
||||
/// Height of one commit row in the commits tab's virtual list.
|
||||
const ROW_HEIGHT: f32 = 37.;
|
||||
@@ -65,29 +55,17 @@ pub struct PullRequestDetailView {
|
||||
current_commit: Option<SharedString>,
|
||||
/// Commits of the patch series, in patch order, oldest first.
|
||||
commits: Vec<FileCommit>,
|
||||
/// Parsed file changes of the patch, `None` while loading or on failure.
|
||||
diff: Option<CommitDiff>,
|
||||
/// The patch is being parsed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
active_tab: usize,
|
||||
/// 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,
|
||||
/// 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,
|
||||
/// Comment bodies as shared strings, keyed by comment event ID.
|
||||
contents: HashMap<EventId, SharedString>,
|
||||
/// In-flight tasks, finished tasks are pruned on every push.
|
||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
@@ -101,7 +79,7 @@ impl PullRequestDetailView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
let pane = cx.new(DiffPane::new);
|
||||
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
@@ -122,18 +100,12 @@ impl PullRequestDetailView {
|
||||
description: SharedString::default(),
|
||||
current_commit: None,
|
||||
commits: Vec::new(),
|
||||
diff: None,
|
||||
loading: true,
|
||||
error: None,
|
||||
active_tab: 0,
|
||||
tree_state,
|
||||
selected_file: None,
|
||||
rows: Vec::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
pane,
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
contents: HashMap::new(),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -270,27 +242,7 @@ impl PullRequestDetailView {
|
||||
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| PathBuf::from(&file.path))
|
||||
.collect();
|
||||
paths.sort();
|
||||
let items = tree_items(build_tree_items(&paths), true);
|
||||
let first = diff
|
||||
.files
|
||||
.first()
|
||||
.map(|file| SharedString::from(file.path.as_str()));
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(items.clone(), cx);
|
||||
let item = find_item(&items, first.as_deref());
|
||||
state.set_selected_item(item, cx);
|
||||
});
|
||||
this.selected_file = first.clone();
|
||||
this.diff = Some(diff);
|
||||
if let Some(path) = first {
|
||||
this.set_diff_rows(path.as_ref());
|
||||
}
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
@@ -307,26 +259,6 @@ impl PullRequestDetailView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// 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 the file at `path` and scroll back to the top.
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
|
||||
return;
|
||||
};
|
||||
self.rows = diff_rows(file);
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]);
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||
fn open_commit_diff(
|
||||
&mut self,
|
||||
@@ -354,206 +286,9 @@ impl PullRequestDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// One row of the changed-files tree, 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.select_file(&id, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.when(self.diff.is_some(), |this| {
|
||||
this.child(
|
||||
tree(&tree_state, move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
})
|
||||
.p_2(),
|
||||
)
|
||||
})
|
||||
.when(self.diff.is_none() && !self.loading, |this| {
|
||||
this.child(placeholder("Failed to load diff", cx))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("Failed to load diff", cx);
|
||||
};
|
||||
|
||||
let Some(path) = self.selected_file.clone() else {
|
||||
return if diff.files.is_empty() {
|
||||
placeholder("No files changed in this pull request", cx)
|
||||
} else {
|
||||
placeholder("Select a file", cx)
|
||||
};
|
||||
};
|
||||
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||
return placeholder("File not found", cx);
|
||||
};
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
signed_git::DiffStatus::Added => "A",
|
||||
signed_git::DiffStatus::Modified => "M",
|
||||
signed_git::DiffStatus::Deleted => "D",
|
||||
signed_git::DiffStatus::Renamed => "R",
|
||||
signed_git::DiffStatus::Copied => "C",
|
||||
};
|
||||
|
||||
let status_color = match file.status {
|
||||
signed_git::DiffStatus::Added => cx.theme().success,
|
||||
signed_git::DiffStatus::Modified => cx.theme().info,
|
||||
signed_git::DiffStatus::Deleted => cx.theme().danger,
|
||||
signed_git::DiffStatus::Renamed | signed_git::DiffStatus::Copied => {
|
||||
cx.theme().muted_foreground
|
||||
}
|
||||
};
|
||||
|
||||
let title = match &file.old_path {
|
||||
Some(old) => format!("{old} → {}", file.path),
|
||||
None => file.path.clone(),
|
||||
};
|
||||
|
||||
let body: AnyElement = if file.binary {
|
||||
placeholder("Diff not available", cx)
|
||||
} else if file.hunks.is_empty() {
|
||||
placeholder("No content changes", cx)
|
||||
} else {
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
v_virtual_list(
|
||||
view,
|
||||
"pr-diff-rows",
|
||||
sizes,
|
||||
move |this, range, _window, cx| {
|
||||
let Some(diff) = this.diff.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(path) = this.selected_file.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
range
|
||||
.map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx))
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(status_color)
|
||||
.child(status_label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(title),
|
||||
)
|
||||
.when(!file.binary, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{}", file.insertions)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("-{}", file.deletions)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("pr-diff-body").flex_1().min_h_0().child(body))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let active = self.active_tab;
|
||||
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
|
||||
let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len());
|
||||
let commits_count = if self.commits.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -575,22 +310,14 @@ impl PullRequestDetailView {
|
||||
Tab::new()
|
||||
.label("Files")
|
||||
.when_some(files_count, |this, count| {
|
||||
this.suffix(
|
||||
Tag::secondary()
|
||||
.xsmall()
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
this.suffix(CountBadge::new(count))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Tab::new()
|
||||
.label("Commits")
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.suffix(
|
||||
Tag::secondary()
|
||||
.xsmall()
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
this.suffix(CountBadge::new(count))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
@@ -670,104 +397,43 @@ impl PullRequestDetailView {
|
||||
this.child(div().text_sm().child(self.description.clone()))
|
||||
}),
|
||||
)
|
||||
.child(self.render_comments(&root_id, cx))
|
||||
.child(self.render_form(&root_id, cx)),
|
||||
.child(comments_section(&self.store, root_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
&self.comment_input,
|
||||
"pr-comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(self.render_sidebar(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar with participants and labels, like the issue panel.
|
||||
fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
// `render_discussion` already bails out when the PR is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants, the PR 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();
|
||||
|
||||
// PR 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()
|
||||
.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
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.child(self.render_tree_column(cx))
|
||||
.child(self.render_detail_column(cx))
|
||||
.child(self.pane.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -876,114 +542,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One comment card, same design as the issue panel.
|
||||
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(id).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);
|
||||
// Comment bodies become shared strings once per comment, not per render.
|
||||
let content = self
|
||||
.contents
|
||||
.entry(comment.id)
|
||||
.or_insert_with(|| SharedString::from(comment.content.clone()))
|
||||
.clone();
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let comment_input = self.comment_input.clone();
|
||||
let store = self.store.clone();
|
||||
let id = id.to_owned();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&self.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("pr-comment")
|
||||
.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) = store
|
||||
.read(cx)
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == id)
|
||||
.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()
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -1147,20 +705,9 @@ fn open_update_pull_request_dialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// One sidebar section title.
|
||||
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()
|
||||
}
|
||||
|
||||
/// The `c` tag of a PR event, the tip of the proposed branch, as hex.
|
||||
fn current_commit_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
/// The `c` tag of a PR event, the commit the proposal points at.
|
||||
fn current_commit_of(root: &Event) -> Option<String> {
|
||||
root.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
@@ -1263,7 +810,7 @@ impl Focusable for PullRequestDetailView {
|
||||
impl Render for PullRequestDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(image_cache("pull-request-detail", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("pull-request-detail"))
|
||||
.id("pull-request-detail")
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
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,
|
||||
@@ -16,7 +16,6 @@ use gpui_component::{
|
||||
use nostr::prelude::{EventId, Kind};
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -126,7 +125,7 @@ impl PullRequestsView {
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,7 +386,7 @@ impl Render for PullRequestsView {
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(image_cache("pull-requests", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("pull-requests"))
|
||||
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
|
||||
if action == &RepoAction::SendPatch {
|
||||
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||
@@ -202,7 +202,7 @@ pub(super) fn open_send_patch_panel(
|
||||
let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
|
||||
|
||||
let _ = dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use gpui_component::{
|
||||
};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, UserAvatar};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -397,7 +396,7 @@ impl Render for RepoListView {
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.image_cache(image_cache("repos", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("repos"))
|
||||
.size_full()
|
||||
.child(self.render_header(count, cx))
|
||||
.when(!has_repos, |this| {
|
||||
|
||||
@@ -2,26 +2,23 @@ use std::path::PathBuf;
|
||||
|
||||
use dock::DockArea;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui::{App, Entity, PathPromptOptions, WeakEntity, Window, div, px};
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
|
||||
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{Backend, CheckoutsStore};
|
||||
|
||||
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.
|
||||
#[derive(Default)]
|
||||
pub struct CreateRepoState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
|
||||
/// Open the Create Repository dialog.
|
||||
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
@@ -117,9 +114,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("create")
|
||||
@@ -211,22 +206,15 @@ fn create_repository(
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Repository name is required".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Add at least one grasp server".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
@@ -254,10 +242,7 @@ fn create_repository(
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
|
||||
use nostr::prelude::*;
|
||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||
use signed_core::filters;
|
||||
use signed_state::Backend;
|
||||
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
@@ -238,30 +237,7 @@ pub fn load_user_grasp_servers(
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result: anyhow::Result<Vec<RelayUrl>> = async {
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filters::grasp_list(user))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events
|
||||
.into_iter()
|
||||
.last()
|
||||
.map(|event| {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "g")
|
||||
.filter_map(|tag| tag.content())
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
.await;
|
||||
let result = signed_state::user_grasp_list_servers(client, user).await;
|
||||
|
||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle};
|
||||
use dock::{
|
||||
BasePanel, DockArea, Panel, PanelEvent, TAB_BAR_HEIGHT, add_center_panel, panel_handle,
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
@@ -18,7 +20,6 @@ use signed_core::{Announcement, identifier_from_name};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||
};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
||||
@@ -154,7 +155,7 @@ impl SidebarPanel {
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,13 +199,7 @@ impl SidebarPanel {
|
||||
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(
|
||||
panel_handle(detail),
|
||||
DockPlacement::Center,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
add_center_panel(dock_area, panel_handle(detail), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -559,7 +554,7 @@ impl Render for SidebarPanel {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.image_cache(image_cache("sidebar", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window, div, px};
|
||||
use gpui::{App, Entity, Window, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the Onboarding dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct OnboardingState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
pub type OnboardingState = DialogProgress;
|
||||
|
||||
/// Open the Onboarding dialog for creating a new identity.
|
||||
pub fn open(
|
||||
@@ -62,9 +60,7 @@ pub fn open(
|
||||
)
|
||||
.child(field().required(true).child(Input::new(&repass_input))),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("continue")
|
||||
@@ -87,17 +83,12 @@ pub fn open(
|
||||
|
||||
if pass != repass {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error =
|
||||
Some("Passphrases do not match".into());
|
||||
state.fail("Passphrases do not match");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_identity(&name, &pass, cx)
|
||||
@@ -115,8 +106,7 @@ pub fn open(
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
state.fail(e.to_string());
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div};
|
||||
use gpui::{AnyWindowHandle, App, Entity, Subscription, Window};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the passphrase dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct PassphraseState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
/// Progress of the unlock flow.
|
||||
pub progress: DialogProgress,
|
||||
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
|
||||
_enter_subscription: Option<Subscription>,
|
||||
}
|
||||
@@ -50,8 +52,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
let busy = state.read(cx).progress.busy;
|
||||
let error = state.read(cx).progress.error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
@@ -70,9 +72,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.child(Input::new(&pass_input)),
|
||||
),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("unlock")
|
||||
@@ -107,15 +107,12 @@ fn unlock(
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Passphrase must not be empty".into());
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.progress.begin());
|
||||
|
||||
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
||||
let handle = *handle;
|
||||
@@ -130,10 +127,7 @@ fn unlock(
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_this, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
state.update(cx, |state, _| state.progress.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -17,12 +17,8 @@ workspace = { path = "../crates/workspace" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
gpui_windows.workspace = true
|
||||
gpui_macos.workspace = true
|
||||
dock = { workspace = true }
|
||||
gpui-component.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
log.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
+6
-4
@@ -74,11 +74,13 @@ fn main() {
|
||||
|
||||
// Connects relays and restores the session.
|
||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||
signed_state::init(paths::nostr_dir(), settings.local_repos.scan_paths, cx);
|
||||
|
||||
// Local git clone cache for browsing repository contents.
|
||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
||||
signed_state::init(
|
||||
paths::nostr_dir(),
|
||||
paths::repos_dir().clone(),
|
||||
settings.local_repos.scan_paths,
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.set_app_identity("su.reya.signed", "Signed");
|
||||
|
||||
|
||||
-657
@@ -1,657 +0,0 @@
|
||||
# PLAN — PR contribution flows: fork compare + GRASP-06 hosting + checkout suggestions
|
||||
|
||||
> **Status (2026-09-03): implemented.** Steps 1-9 and 11 are done on
|
||||
> `feat/fork`; step 10 (sidebar "Ready to contribute" group) remains
|
||||
> deferred as planned (v2, optional). See `docs/PR_FLOW.md` for the
|
||||
> resulting flow; the per-step sections below record what shipped and
|
||||
> where the plan was refined during implementation.
|
||||
|
||||
Combined implementation plan for three coordinated improvements to Signed's pull
|
||||
request experience:
|
||||
|
||||
- **A. Fork-aware compare** — the New PR panel's compare side can come from an
|
||||
announced fork repository's branch (fetched into the base repo's GitCache
|
||||
mirror), instead of only from a user-picked local checkout.
|
||||
- **B. GRASP-06 hosting** — PR tips are pushed to the *author's own* grasp
|
||||
servers under `/prs/<author-npub>/<repo-id>.git` and advertised in the PR's
|
||||
`clone` tag, so contributing to someone else's project never depends on their
|
||||
servers accepting anything from you.
|
||||
- **C. Checkout associations & suggestions** — remember/derive which local
|
||||
folders are checkouts of which announced repos, auto-prefill the New PR panel
|
||||
(no folder picker for the common case), and suggest creating a PR when a
|
||||
branch is ahead with no open PR (GitHub-like nudge, NIP-34-native dedupe).
|
||||
|
||||
Guiding principles (agreed): follow nostr + grasp + ngit, not GitHub;
|
||||
NIP-34/GRASP-06 event surface stays untouched (no new tags/kinds, forks never
|
||||
appear in events); every flow keeps the patch series as the source of truth;
|
||||
no over-engineering — reuse existing stores, patterns and git helpers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol grounding (what we may and may not do)
|
||||
|
||||
### 1.1 NIP-34 facts used by this plan
|
||||
|
||||
- A PR (kind-1618) is addressed to the **base** repo coordinate (`a` tag) and
|
||||
carries `c` (tip), `merge-base` (common ancestor with the target branch),
|
||||
`branch-name`, `clone` (≥1 URL where the tip commit can be downloaded),
|
||||
`e` → root patch event, `r` (EUC), `p` (base owner). Patches are
|
||||
NIP-10-chained kind-1617 events, ≤60 KB each. Statuses 1630–1633 resolve the
|
||||
PR.
|
||||
- Repository announcements (30617): `u` tag marks a subordinate fork
|
||||
(`30617:<pubkey>:<id>` coordinate or git URL); the `r`/`euc` tag identifies
|
||||
the earliest unique commit, shared by every repo of the same project family
|
||||
(forks, mirrors). Both are **read-only inputs** for discovery.
|
||||
- Kind-10317 is the user grasp list (`g` tags, in preference order) — read-only
|
||||
input for hosting.
|
||||
- Anybody may open a PR on any announced repo; only the author may update it
|
||||
(1619); only the author or a maintainer may set status; merge is the
|
||||
maintainer's action.
|
||||
- "Patches and PRs to a repository SHOULD be sent to the relays specified in
|
||||
that repository's announcement" — i.e. the **base** repo's relays, always.
|
||||
|
||||
### 1.2 GRASP-06 facts (as ngit implements it — verified against ngit-cli)
|
||||
|
||||
- GRASP-06 servers expose a contributor namespace:
|
||||
`http(s)://<host>/prs/<contributor-npub>/<repo-id>.git` (input
|
||||
`ws://`/`wss://` base URLs normalize to `http(s)://`; npub in the URL, hex
|
||||
on the server's disk — a server detail). Anyone can push there; no
|
||||
announcement, no maintainer rights, no fork repo required.
|
||||
- The author's server is tried **first**; the base repo's announcement grasps
|
||||
still receive the same `refs/nostr/<event-id>` push as redundancy.
|
||||
- The PR event shape is unchanged; only *which URLs the `clone` tag lists*
|
||||
differs.
|
||||
|
||||
### 1.3 Consequences (locked decisions)
|
||||
|
||||
- Publishing keeps today's event set and tag semantics. The fork changes only
|
||||
where the patch series is generated from; GRASP-06 changes only where the tip
|
||||
is pushed and advertised; suggestions change nothing on the wire.
|
||||
- We never publish a reference to the fork or to `/prs/` hosting beyond legal
|
||||
`clone` URLs.
|
||||
- The PR `clone` tag is fixed before signing (the `refs/nostr/<event-id>` ref
|
||||
name embeds the event id), so it carries the full candidate URL set
|
||||
(author `/prs/` URLs + base announcement clone URLs). Dead URLs are inert —
|
||||
patch events remain the truth — and ngit readers fail over across URLs.
|
||||
(ngit instead rebuilds the event per server to keep a single clone URL; we
|
||||
deliberately do not copy that.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Workstream A — Fork-aware compare
|
||||
|
||||
### 2.1 Model
|
||||
|
||||
The panel keeps today's behavior as the default source and adds a second:
|
||||
|
||||
- **Checkout** (existing): both selectors list a user-picked local checkout's
|
||||
branches; git ops + tip push run in the checkout.
|
||||
- **Fork** (new): the flow runs against the **base repo's GitCache mirror**
|
||||
`P_base = GitStore::global(cx).cache().repo_path(&base_addr)` (ensured via
|
||||
`GitCache::ensure_clone(&base_addr, &base_clone_urls)` + `fetch_all`):
|
||||
- "Merge Into" lists `P_base` branches (`refs/remotes/origin/*`);
|
||||
- "Pull From" lists the chosen fork's branches, imported into `P_base`;
|
||||
- `merge-base`, range commits/diff, `format-patch`, and the tip push all run
|
||||
against `P_base` — both histories share one object store, and commit-diff
|
||||
rows work because fork commits live there.
|
||||
|
||||
### 2.2 Git mechanics (import namespace)
|
||||
|
||||
Fork heads are fetched into `P_base` under a private namespace:
|
||||
|
||||
```
|
||||
git -C P_base fetch <fork-clone-url> '+refs/heads/*:refs/fork/<owner-hex>/<sanitized-id>/*'
|
||||
```
|
||||
|
||||
- `refs/fork/…` keeps imported refs away from `refs/remotes/*` and
|
||||
`refs/heads/*`, so the repo browser, `repo_branches` and DWIM checkout never
|
||||
see them.
|
||||
- Fetch tries each announced `clone` URL until one works (`grasp://` →
|
||||
`https://` rewrite, `GIT_TERMINAL_PROMPT=0`), like `clone_repo` /
|
||||
`push_commit_ref`.
|
||||
- Switching fork or refreshing: prune the old `refs/fork/<owner>/<id>/*`
|
||||
prefix first (`git update-ref --stdin` fed by `for-each-ref`), then
|
||||
re-import. All-heads import in one fetch; subsequent branch switches within
|
||||
the same fork are offline.
|
||||
- Range work uses full refs: `merge_base(P_base,
|
||||
"refs/remotes/origin/<base>", "refs/fork/…/<compare>")`, then the existing
|
||||
`worktree_commit_range_commits`/`worktree_commit_range_diff` /
|
||||
`format_patch_between`. None of these touch the checkout state.
|
||||
- Base `main` and fork `main` are different refs: the "choose different
|
||||
branches" guard compares full refs, display names stay short.
|
||||
|
||||
### 2.3 Fork discovery
|
||||
|
||||
Candidates = `RepoListStore::global(cx).read(cx).announcements` (already
|
||||
deletion-filtered, latest-wins) where
|
||||
`Announcement::is_fork_of(base_addr, base_euc)`:
|
||||
|
||||
- `upstream.addr == Some(base_addr)` (the `u` tag — also covers permanent
|
||||
forks whose EUC changed), **or**
|
||||
- `euc == base announcement's euc` (shared earliest-unique-commit family),
|
||||
excluding the base repo itself.
|
||||
|
||||
Ordering (identity-coherent, ngit-style): **your own forks first** (30617
|
||||
owner == signed-in user), then other authors' related repos (same mechanics,
|
||||
marked, niche). Announcements without `clone` URLs are excluded (unfetchable).
|
||||
Restricting to your own forks only later is a one-line ownership filter.
|
||||
|
||||
### 2.4 Panel behavior
|
||||
|
||||
- Defaults mirror `apply_checkout`: base = announced `store.head` if present in
|
||||
mirror branches, else `main`, else first; compare = fork's `main`, else
|
||||
first fork branch.
|
||||
- `submit`: `format_patch_between(P_base, merge_base, compare_ref)`; published
|
||||
`branch-name` = compare short name; `push_from = Some(P_base)` (fork objects
|
||||
are there after import). Publishing itself is workstream B.
|
||||
- `open_commit_diff` uses `P_base` in fork mode.
|
||||
- Errors: no common ancestor → existing message; unreachable base mirror or
|
||||
fork → inline error; empty range → existing "no commits to propose".
|
||||
|
||||
---
|
||||
|
||||
## 3. Workstream B — GRASP-06 author hosting
|
||||
|
||||
Applies to **every** PR publish from a repo path that has the objects —
|
||||
checkout mode and fork mode alike. `RepoStore::open_pull_request` keeps its
|
||||
signature; internals change:
|
||||
|
||||
1. **Resolve author grasp servers** (new shared helper): latest kind-10317
|
||||
grasp list of the signed-in user from the local DB (`filters::grasp_list`,
|
||||
`g` tags in order) → **fallback to settings defaults**
|
||||
(`GraspServersSettings.default_servers` / `DEFAULT_GRASP_SERVERS`, the same
|
||||
source the create-repo dialogs use) when no list is published.
|
||||
2. **Build `/prs/` URLs**: `grasp_base_url(server) + "/prs/" + user_npub +
|
||||
"/" + base_repo_id + ".git"` (npub form, like ngit; `grasp_base_url` maps
|
||||
wss→https, ws→http).
|
||||
3. **`clone` tag** = dedup of `/prs/` URLs plus the current base-announcement
|
||||
clone URLs (order: `/prs/` first — the author's servers are the most likely
|
||||
to be alive and author-controlled).
|
||||
4. **Push loop** = author `/prs/` servers first (guaranteed writable — the
|
||||
point of GRASP-06), then the base announcement grasp servers (existing
|
||||
behavior), all `refs/nostr/<event-id>` from `push_from`. Best-effort;
|
||||
zero successes → existing `last_warning` banner; publishing always
|
||||
proceeds.
|
||||
|
||||
Effect: a repo announced with relays but no reachable grasp hosting still gets
|
||||
a downloadable tip (on the author's own hosting), and git-native clients
|
||||
(ngit, `git-remote-nostr`) can fetch Signed PR tips from the `clone` URL.
|
||||
|
||||
**1619 updates are out of scope for v1**: the update dialog is paste-only, so
|
||||
no repo path holds the new tip's objects. Deferred until the existing
|
||||
"local-checkout generation for the update-PR dialog" TODO lands; then push the
|
||||
new tip to the same `/prs/` set under the PR's stable ref
|
||||
(`refs/nostr/<root-pr-event-id>`, advanced per revision — convention to verify
|
||||
against ngit-grasp first).
|
||||
|
||||
---
|
||||
|
||||
## 4. Workstream C — Checkout associations & suggestions
|
||||
|
||||
Three tiers: **Remember → Auto-pick → Suggest**.
|
||||
|
||||
### 4.1 Remember (associations)
|
||||
|
||||
A local folder ↔ announced repo association comes from two sources:
|
||||
|
||||
- **Explicit** (persistent settings records `{path, addr, last_used}`):
|
||||
recorded when the repo header **Clone** action succeeds (addr known) and
|
||||
when a folder pick succeeds in the New PR panel (store addr known).
|
||||
- **Implicit** (derived, no persistence): among `LocalReposStore` scan results
|
||||
(settings `local_repos.scan_paths`), a repo whose
|
||||
- `origin` URL matches an announcement `clone` URL (compare host+path,
|
||||
ignoring scheme: ws/wss/http/https/grasp are equivalent transports of the
|
||||
same grasp URL), or
|
||||
- root commit equals the announcement EUC
|
||||
is a checkout of that announced repo.
|
||||
|
||||
Resolution order per repo: remembered (freshest first) ∪ scanned-matched,
|
||||
deduplicated by path, skipping missing directories.
|
||||
|
||||
### 4.2 Auto-pick (New PR panel prefill)
|
||||
|
||||
`open_new_pull_panel(…)` gains a suggested-checkout parameter, resolved by the
|
||||
caller from the association store:
|
||||
|
||||
- **Exactly one** checkout → auto-apply it: selectors populate, base =
|
||||
announced HEAD, compare = current branch, diff loads. The folder button
|
||||
becomes "Change…".
|
||||
- **Several** → a small folder combobox instead of the modal folder picker.
|
||||
- **None** → today's flow unchanged.
|
||||
- Successful manual folder picks are recorded back (learning).
|
||||
|
||||
### 4.3 Suggest (status + surfaces)
|
||||
|
||||
A small checkout-status computation (part of the association store), scoped to
|
||||
the bounded set of associated checkouts, on background threads:
|
||||
|
||||
- Triggers: app open, window focus (debounced ~5 s), `LocalReposStore` rescan,
|
||||
`BackendEvent::Synced`.
|
||||
- Per checkout: current branch; commits ahead of the base branch (announced
|
||||
HEAD name if present locally, else `main`, else first local branch — the
|
||||
same rule as `apply_checkout`), via `rev-list --count`; whether the user has
|
||||
an **open** PR from that branch on the target repo (author == me,
|
||||
`branch-name` tag == branch, fallback: tip `c` tag == local HEAD).
|
||||
- Result states: `ReadyToCreate { target, branch, ahead, base }` /
|
||||
`HasOpenPr { … }` / `Idle`.
|
||||
- Noise rules: only when ahead > 0 and branch ≠ base; nothing for dirty
|
||||
worktrees; one entry per target repo.
|
||||
|
||||
Surfaces:
|
||||
|
||||
| Surface | Shows | Dedupe data source | Scope |
|
||||
|---|---|---|---|
|
||||
| Repo **PR list** banner (`PullRequestsView`) | "branch `feature` is 3 commits ahead of `main` — Create pull request →" (opens prefilled New PR) | live open `RepoStore` (precise) | v1 |
|
||||
| **Sidebar** "Ready to contribute" group | row per `ReadyToCreate`: target repo, branch ↑N → opens target repo + prefilled New PR | v1: only targets with a live open store, else a local-DB query refreshed after a lazy per-target bootstrap activity sync; if the repo has no data yet, the group omits it (no false "ready") | v2 (after v1 proves out) |
|
||||
| Repo detail header chip | tiny `feature ↑3` on the repo whose checkout is ahead | as PR-list banner | optional |
|
||||
|
||||
The PullRequestsView banner reuses the existing dismissible `Alert` banner
|
||||
pattern already used for store errors/warnings.
|
||||
|
||||
---
|
||||
|
||||
## 5. Combined flow (fork mode, end to end)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (New PR panel)
|
||||
participant P as Base mirror (GitCache)
|
||||
participant F as Fork grasp server
|
||||
participant A as Author grasp (GRASP-06 /prs/)
|
||||
participant B as Base repo grasps
|
||||
participant R as Nostr relays
|
||||
|
||||
U->>U: pick fork repo (u/EUC relation, yours first) + branch
|
||||
U->>P: ensure_clone(base) + fetch_all
|
||||
P-->>F: fetch +refs/heads/*:refs/fork/<owner>/<id>/*
|
||||
P-->>U: base branches (origin/*) + fork branches (refs/fork/…)
|
||||
U->>P: merge-base, range commits, range diff (Files/Commits tabs)
|
||||
U->>P: submit: format-patch merge-base..fork-ref
|
||||
U->>R: publish kind-1617 series (root + NIP-10 chain, ≤60 KB each)
|
||||
U->>R: sign kind-1618 (a=base, c=fork tip, merge-base, branch-name, clone=[/prs/…, base clone URLs], e=root patch, r=EUC)
|
||||
U->>A: push tip → refs/nostr/<event-id> (author servers, first)
|
||||
U->>B: push tip → refs/nostr/<event-id> (best-effort redundancy)
|
||||
U->>R: publish kind-1618
|
||||
Note over R: zero successful pushes → last_warning banner only
|
||||
```
|
||||
|
||||
Checkout mode is identical except the fork-import step; suggestions (workstream
|
||||
C) only add entry-point shortcuts into this flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Step-by-step implementation
|
||||
|
||||
Phases are ordered so each step lands on green: foundations first, then the
|
||||
publish-side change (benefits the existing checkout flow immediately), then
|
||||
the fork UI, then the UX layer. Every step compiles, passes its tests, and
|
||||
keeps existing behavior unchanged.
|
||||
|
||||
### Phase 0 — Foundations
|
||||
|
||||
#### Step 1 — `signed_core`: fork relation predicate
|
||||
|
||||
- File: `crates/signed_core/src/model.rs`.
|
||||
- Add `Announcement::is_fork_of(&self, base: &RepoAddr, base_euc:
|
||||
Option<&str>) -> bool`:
|
||||
`upstream.addr == Some(base)` OR (`base_euc` present AND `self.euc ==
|
||||
base_euc`), excluding self (same owner + id).
|
||||
- Tests: u-tag coordinate match; shared EUC match; permanent fork with
|
||||
different EUC matched via `u`; no-match; base-self exclusion.
|
||||
- Done when: predicate + tests green; used by Step 5.
|
||||
|
||||
#### Step 2 — `signed_git`: mirror/import primitives
|
||||
|
||||
- File: `crates/signed_git/src/lib.rs`.
|
||||
- `fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) ->
|
||||
Result<()>` — CLI `git fetch <url> <refspec>`, grasp:// → https rewrite,
|
||||
`GIT_TERMINAL_PROMPT=0`, try each URL until one works (last-error on all
|
||||
failing, like `clone_repo`).
|
||||
- `refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>>` —
|
||||
full refnames under `prefix` (`git for-each-ref --format=%(refname)`),
|
||||
sorted.
|
||||
- `delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()>` —
|
||||
collect via `for-each-ref`, delete via `git update-ref --stdin` lines.
|
||||
- `origin_url(workdir: &Path) -> Result<Option<String>>` (used by Step 7).
|
||||
- Tests (existing `file://` fixture infra): import under a target prefix;
|
||||
URL fallback to the working server; prefix listing; prefix deletion;
|
||||
origin URL read.
|
||||
- Done when: helpers + tests green.
|
||||
|
||||
### Phase 1 — GRASP-06 hosting (publish side)
|
||||
|
||||
#### Step 3 — author grasp-server resolution
|
||||
|
||||
- File: `crates/signed_state/src/backend.rs` (or a small new module).
|
||||
- `resolve_user_grasp_servers(cx, user) -> Vec<RelayUrl>`: latest kind-10317
|
||||
of `user` from the local DB (`filters::grasp_list`; latest event wins; `g`
|
||||
tags in order) → fallback to settings `GraspServersSettings`
|
||||
defaults/`DEFAULT_GRASP_SERVERS` when the user has no grasp list.
|
||||
- Refactor the create-repo/init dialogs to share it (optional, keeps one
|
||||
resolution path).
|
||||
- Tests: latest-wins selection; missing list falls back; `g` order preserved.
|
||||
- Done when: helper + tests green.
|
||||
|
||||
#### Step 4 — `open_pull_request` hosting
|
||||
|
||||
- File: `crates/signed_state/src/repo.rs` (`open_pull_request`, ~L657).
|
||||
- Pure helpers (unit-testable): `grasp06_prs_url(base_url: &str, npub:
|
||||
&str, repo_id: &str) -> String`; push-target assembly (author `/prs/`
|
||||
URLs first, then announcement grasp URLs; dedup).
|
||||
- `clone` tag = `/prs/` URLs (author npub from `Backend::current_user()`,
|
||||
repo id from `self.addr().identifier`) + base announcement clone URLs.
|
||||
- Push loop extended: existing per-relay loop stays; author servers push to
|
||||
the `/prs/` URL instead of the repo URL. Warning semantics unchanged.
|
||||
- Tests: URL building; target order; dedup. Behavioral coverage of the full
|
||||
publish is manual/e2e (see §8) until a harness exists.
|
||||
- Done when: checkout-mode PRs push to the author's grasp list
|
||||
(`/prs/<npub>/<repo-id>.git`) first and the `clone` tag carries those URLs;
|
||||
all-servers-fail still publishes with a warning.
|
||||
|
||||
### Phase 2 — Fork-aware compare
|
||||
|
||||
#### Step 5 — fork candidates
|
||||
|
||||
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs` (helper)
|
||||
or `crates/signed_state`.
|
||||
- `fork_candidates(cx) -> Vec<Announcement>`: filter
|
||||
`RepoListStore::global().announcements` with `is_fork_of` (Step 1); exclude
|
||||
empty `clone`; sort own forks (owner == current user) first, then others,
|
||||
each group by recency/name. Re-read each time the picker opens.
|
||||
- Done when: helper returns the expected ordering for a mixed list.
|
||||
|
||||
#### Step 6 — New PR panel fork mode
|
||||
|
||||
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs`.
|
||||
- State: `CompareSource { Checkout, Fork { announcement } }`; per-mode item
|
||||
sets for both selectors; `mirror_path`; fork branch list; full-ref
|
||||
base/compare tracking (display keeps short names).
|
||||
- `choose_fork(announcement)` + `prepare_fork` (mirror `choose_checkout` /
|
||||
`apply_checkout` async shape, `compare_generation` guard): ensure base
|
||||
mirror (`ensure_clone` + `fetch_all`), prune previous `refs/fork/…`
|
||||
prefix, import fork heads (`fetch_repo_refs`), list both ref sets
|
||||
(`refs_with_prefix`), populate selectors with defaults, `reload_compare`.
|
||||
- `reload_compare` / `submit` / `open_commit_diff` become mode-aware (path +
|
||||
base ref + compare ref resolution; `format_patch_between` and
|
||||
`push_from` on `P_base`; published `branch-name` = short name).
|
||||
- UI (compare bar): source control next to "Pull From" (local checkout /
|
||||
announced fork), fork-repo combobox (grouped, own forks first), refresh
|
||||
affordance, "Change…" back to checkout; loading spinner; inline errors.
|
||||
- Done when: checkout mode is byte-identical in behavior; fork mode shows
|
||||
base/fork selectors, Files/Commits tabs, commit diffs, and publishes with
|
||||
the correct tags (manual §8).
|
||||
|
||||
### Phase 3 — Checkout associations & suggestions
|
||||
|
||||
#### Step 7 — association store
|
||||
|
||||
- Files: `crates/settings` (extend the settings model like
|
||||
`local_repos.scan_paths` with remembered checkouts
|
||||
`{path, addr, last_used}`); new `crates/signed_state/src/checkouts.rs`
|
||||
(global store, `Arc` + debounce pattern from `LocalReposStore`/
|
||||
`RepoListStore`).
|
||||
- API: `associations_for(addr) -> Vec<PathBuf>` (remembered freshest-first ∪
|
||||
scanned-matched by origin URL/EUC via Step 2's `origin_url` +
|
||||
`signed_git::root_commit`; scheme-insensitive URL compare; dedup; skip
|
||||
missing dirs); `record(path, addr)`.
|
||||
- Recording hooks: repo header `clone_to_folder` success
|
||||
(`repo_detail/mod.rs`) and `choose_checkout` success (panel).
|
||||
- Tests: matching by origin URL (scheme variants), by EUC, no match; dedup
|
||||
and ordering.
|
||||
- Done when: associations resolve correctly and persist.
|
||||
|
||||
#### Step 8 — New PR prefill
|
||||
|
||||
- Files: `new_pull_request.rs` (`open_new_pull_panel` + `new`); callers
|
||||
`repo_detail/mod.rs` header and `pull_requests.rs`.
|
||||
- Entry param `suggested_checkout: Option<PathBuf>` (default `None`);
|
||||
panel applies it on construction when the folder still exists, else falls
|
||||
back to the empty state. When several candidates exist the caller passes
|
||||
the freshest and the panel offers the others through a folder combobox
|
||||
(new small control next to the source button).
|
||||
- Done when: opening New PR on a repo with a remembered/matched checkout
|
||||
never shows the folder dialog; manual picks get remembered.
|
||||
|
||||
#### Step 9 — status computation + PR-list banner
|
||||
|
||||
- Files: `checkouts.rs` (status states + triggers + debounce), workspace
|
||||
`pull_requests.rs` (banner), `new_pull_request.rs` (accepts the banner's
|
||||
"create" click by opening prefilled).
|
||||
- Status rules from §4.3; banner dedupe against the live open `RepoStore`
|
||||
(author + `branch-name`, fallback tip match, open status only).
|
||||
- Done when: after committing on an associated checkout and opening the
|
||||
target repo's PR list, the banner appears exactly when ahead > 0 and no
|
||||
open PR exists, and disappears after creating/merging/evening.
|
||||
|
||||
#### Step 10 — sidebar "Ready to contribute" group (v2, optional)
|
||||
|
||||
- File: `crates/workspace/src/views/sidebar/mod.rs` (+ `checkouts.rs`
|
||||
support).
|
||||
- Only list targets with reliable dedupe data (live open store, else a
|
||||
local-DB activity query refreshed after a lazy per-target bootstrap
|
||||
activity sync); omit everything uncertain. Clicking a row opens the target
|
||||
repo (`open_repo_panel`) + prefilled New PR.
|
||||
- Done when: rows appear without false "ready" entries (dedupe-uncertain
|
||||
targets omitted).
|
||||
|
||||
### Phase 4 — Docs & validation
|
||||
|
||||
#### Step 11 — documentation and final validation
|
||||
|
||||
- Update `docs/PR_FLOW.md`: fork compare path, GRASP-06 server set + clone
|
||||
tag, suggestion surfaces; the mermaid sequence in §5.
|
||||
- Update `docs/TODO.md`: tick "Fork-aware compare…", "GRASP-06 …"; add the
|
||||
checkout-suggestions item; keep deferred items (1619 update push, sidebar
|
||||
group, reading-side clone-URL fetch) explicit.
|
||||
- Run the manual validation checklist (§8) end to end.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error handling & edge cases (all inline or warnings, as today)
|
||||
|
||||
- Base mirror unreachable / no base `clone` URLs → panel error in fork mode;
|
||||
checkout mode unaffected.
|
||||
- Fork unreachable / without `clone` URLs (excluded from candidates) → panel
|
||||
error.
|
||||
- No common ancestor → existing error (range flow needs shared history; Send
|
||||
Patch remains the fallback).
|
||||
- Author has no 10317 list and no default servers → GRASP-06 adds nothing;
|
||||
today's warning stands.
|
||||
- Author's grasp server does not implement `/prs/` → its push fails silently
|
||||
in the loop; its URL in `clone` is inert; base grasps still tried.
|
||||
- Fork branch deleted upstream / fork switched → prune prefix + re-import;
|
||||
generation guard discards stale compares.
|
||||
- Concurrency on `P_base` with the repo browser: we never checkout; git ref
|
||||
locks make overlapping fetches safe (same class as today's browser refresh).
|
||||
- Multiple checkouts of one repo → freshest first, "Change…"/combobox for the
|
||||
rest.
|
||||
- Branch renamed after a PR → dedupe falls back to tip-commit matching;
|
||||
otherwise a duplicate suggestion may appear once (accepted v1 tradeoff).
|
||||
- Suggestions never block UI; results arrive as `Arc` swaps.
|
||||
|
||||
## 8. Non-goals / deferred (explicitly out of scope)
|
||||
|
||||
- 1619 update hosting (depends on the local-checkout update-dialog TODO).
|
||||
- Paste/Send-Patch flow keeps no git push (no object store; patches = truth;
|
||||
no scratch-apply resurrection).
|
||||
- Reading side: fetching other clients' PR tips from `clone` URLs into the
|
||||
mirror (`ngit pr checkout` analog) — only needed for patch-less PRs.
|
||||
- Fork creation UI (Signed still cannot announce forks; they come from ngit or
|
||||
by publishing a clone) — fork candidates simply won't include non-existent
|
||||
ones.
|
||||
- GitHub-isms rejected: no fork-network browser, no per-fork PR pages, no
|
||||
fork identity in events, no "compare across forks" for strangers' branches
|
||||
beyond what is listed above.
|
||||
- The panel never auto-submits anything; suggestions only navigate and
|
||||
prefill.
|
||||
|
||||
## 9. Validation checklist (manual e2e)
|
||||
|
||||
1. Checkout mode regression: clone a repo to disk, branch + commit (external
|
||||
git), New PR → choose folder → diff/commits → Create → PR appears on the
|
||||
target repo's PR list; tip pushed to the author's `/prs/` server(s) from
|
||||
the 10317 list (fallback: defaults); `clone` tag lists `/prs/` URLs first.
|
||||
2. All grasp servers down/absent → PR still publishes; warning banner shows.
|
||||
3. Fork mode: with a fork announcement related to the base (own fork first,
|
||||
other author's fork listed), pick repo + branch → selectors, Files/Commits
|
||||
tabs, commit-diff rows correct; published 1618 carries `a` = base
|
||||
coordinate, `c` = fork tip, `merge-base` = fork point, `branch-name` =
|
||||
fork branch; tip fetchable from the advertised `/prs/` URL via a plain
|
||||
`git fetch`.
|
||||
4. Prefill: reopen New PR for the same repo → folder auto-chosen, selectors
|
||||
populated; "Change…" works.
|
||||
5. Banner: with the repo's PR list open and an associated checkout ahead with
|
||||
no open PR → banner appears; disappears after publishing a PR, after
|
||||
merging, and when the branch is even.
|
||||
6. Interop: an ngit/git client fetches a Signed PR's tip from the `/prs/`
|
||||
clone URL (requires a GRASP-06-enabled server).
|
||||
|
||||
## 10. Implementation log (2026-09-03, branch `feat/fork`)
|
||||
|
||||
All steps below landed with unit tests; `cargo test` across `signed_core`
|
||||
(44), `signed_git` (61), `signed_state` (15), `workspace` (14) and
|
||||
`settings` (9) is green, and `cargo check` on the whole workspace passes.
|
||||
The manual e2e checklist above still needs a real GRASP-06 server run.
|
||||
|
||||
- **Step 1** — `Announcement::is_fork_of` (`signed_core::model`) + 4 tests.
|
||||
- **Step 2** — `signed_git`: `fetch_repo_refs`, `refs_with_prefix`,
|
||||
`delete_refs_with_prefix`, `origin_url`, `GitCache::root()` + 4 tests
|
||||
(import/list/prune against `file://` fixtures incl. URL fallback).
|
||||
- **Step 3** — backend grasp-list resolution: `grasp_list_servers`,
|
||||
`latest_grasp_list_servers`, `user_grasp_list_servers` (DB query, latest
|
||||
wins) + `grasp06_prs_url` and `pr_clone_urls` (author-first, dedup) + 4
|
||||
tests. `signed_state` gained a `settings` dependency for the defaults
|
||||
fallback.
|
||||
- **Step 4** — `RepoStore::open_pull_request` (signature unchanged):
|
||||
resolves the author's grasp servers (10317 → settings defaults) inside
|
||||
the publish task, builds the `clone` tag from `/prs/` URLs first, pushes
|
||||
author `/prs/` targets before the base announcement's servers, deduped;
|
||||
all-fail keeps the `last_warning` banner. 1619 updates untouched
|
||||
(deferred, as planned).
|
||||
- **Step 5** — `fork_candidates` ordering helper + 2 tests (own forks
|
||||
first; base/unrelated/no-clone excluded; EUC-less base still matches via
|
||||
`u`).
|
||||
- **Step 6** — New PR panel fork mode: `ForkCompare` state, `choose_fork`/
|
||||
`apply_fork` (mirror `ensure_clone` → prune `refs/fork` → import → list
|
||||
both ref sets), mode-aware `base_ref`/`compare_ref`/`work_path` used by
|
||||
`reload_compare`/`submit`/`open_commit_diff`, stale-result guard,
|
||||
refresh-by-re-picking + refresh button, and a "Source" picker menu
|
||||
(checkout rows + forks) replacing the folder button. Checkout mode stays
|
||||
byte-identical in behavior. Deviations from the plan: selectors and the
|
||||
source picker keep one shared layout (no separate fork-repo combobox —
|
||||
the source menu lists forks grouped own-first, matching the ordering
|
||||
requirement); `IconName::GitBranch` does not exist upstream so fork rows
|
||||
use the project's `CustomIconName::GitBranch`.
|
||||
- **Step 7** — settings `CheckoutRecord`/`CheckoutsSettings` group + new
|
||||
`signed_state::checkouts::CheckoutsStore` global (observe settings /
|
||||
local scan / announcements; debounced, coalesced, Arc-swapped):
|
||||
scheme-insensitive `same_repo_url`, `resolve_associations` (remembered
|
||||
freshest-first ∪ scanned origin/EUC matches, dedup, mirror-cache paths
|
||||
excluded), `record()`, per-repo `request_statuses`/`statuses_of` with
|
||||
`CheckoutStatus` (branch/head/base/ahead; dirty and detached checkouts
|
||||
never suggested; 15 s poll while any PR list is open). 7 tests.
|
||||
Deviations: settings records store the address as a string (the settings
|
||||
crate stays free of nostr types); mirror exclusion uses the cache root
|
||||
(new `GitCache::root()`); statuses are computed per requested repo with
|
||||
the announced HEAD supplied by the open list panel rather than from a
|
||||
30618 DB query.
|
||||
- **Step 8** — New PR panel prefills the freshest associated checkout on
|
||||
construction (no folder dialog); `apply_folder_path` applies a given
|
||||
path; successful folder picks and header clones are recorded back; the
|
||||
Source menu lists associated checkouts (checked when applied) plus
|
||||
"Choose another folder…". Deviations: instead of a separate folder
|
||||
combobox, the alternatives live in the Source menu (fewer controls, same
|
||||
outcome); `open_new_pull_panel` needed no signature change because the
|
||||
panel reads the association store itself.
|
||||
- **Step 9** — "ready to contribute" banner: `RepoDetailView` requests
|
||||
the statuses while the repository panel is open (re-requested when the
|
||||
announced HEAD lands or changes) and renders the banner under the repo
|
||||
header; the first ready checkout not covered by an open PR of the
|
||||
signed-in user (`branch-name`, fallback `c`-tag tip) and not dismissed
|
||||
(per-panel dismissal set) is offered with a Create button opening the
|
||||
prefilled panel. The dedupe predicate is the tested
|
||||
`pr_proposes_checkout` in `signed_state::checkouts`. Deviation from the
|
||||
plan: the surface is the repository panel (not the PR-list panel, as the
|
||||
user requested after v1; the PR list keeps only its error/warning
|
||||
banners), and there is no window-focus trigger (no precedent in the
|
||||
codebase; the 15 s poll plus open/rescan/settings triggers cover the
|
||||
plan's "done when" cases).
|
||||
- **Step 10** — deferred (v2, optional), per plan; the banner is the v1
|
||||
surface.
|
||||
- **Step 11** — `docs/PR_FLOW.md` rewritten for the current panel flow
|
||||
(fork import, GRASP-06 hosting, suggestions, deferred items explicit);
|
||||
`docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run
|
||||
against a live GRASP-06 server.
|
||||
- **Fix (after e2e, user report)** — creating a repository left the
|
||||
project only inside the app's GitCache mirror: the announcement, state
|
||||
event and push happened, but the folder chosen in the Create Repository
|
||||
dialog was just remembered as a settings default. `Backend::
|
||||
create_repository` now also materializes a working copy at
|
||||
`<folder>/<sanitized-name>` (cloned from the mirror via a `file://` URL
|
||||
so it shares the announced history exactly, then `origin` re-pointed at
|
||||
the first grasp server through the new `signed_git::set_origin`), and
|
||||
the dialog records it as a checkout (`CheckoutsStore`, so the New PR
|
||||
panel pre-fills it), opens it in the system file manager and opens the
|
||||
repository panel. Materialization runs before any event is published,
|
||||
so a failure aborts creation cleanly with nothing announced. Two new
|
||||
`signed_git` tests (`set_origin_creates_or_replaces_the_remote`,
|
||||
`working_copy_cloned_from_the_mirror_matches_head_and_origin`).
|
||||
- **Add (after e2e, user request)** — "ready to push" watch for the
|
||||
user's own repositories: local commits made in a checkout (external
|
||||
git) surface as a **sidebar badge** on the repository row (a
|
||||
`CountBadge` with the unpushed commit count) and, when the repository
|
||||
panel is open, as an info **banner with a Push button**. The
|
||||
`CheckoutsStore` gains a second status family (`request_push_statuses`/
|
||||
`push_statuses_of`): per checked-out branch it refreshes the remote
|
||||
view (`git fetch` of the checkout's origin, offline-tolerant) and
|
||||
counts `origin/<branch>..<branch>` (`origin/HEAD` for branches the
|
||||
remote does not have yet); dirty/detached checkouts are skipped like
|
||||
the PR suggestions. Poll cadence: 15 s while a repository panel is
|
||||
open (`status_requested`), 60 s for the sidebar-only background watch;
|
||||
request sets are cleared on signer change. The repo panel's
|
||||
ready-to-contribute banner now applies only to repositories of other
|
||||
authors — owned repositories get the push banner instead, whose Push
|
||||
action calls the new `Backend::push_checkout` (shared body with the
|
||||
existing mirror-based `push_repository`): publishes a fresh 30618
|
||||
state event (keeping the announced `HEAD` branch when the checkout is
|
||||
on a side branch) then pushes every branch and tag to the announced
|
||||
grasp servers. New test
|
||||
`checkout_push_status_counts_unpushed_commits_only`. The mirror's file
|
||||
browser stays a snapshot (new commits appear after a branch switch),
|
||||
like the rest of the browser.
|
||||
- **Fix (after e2e, user report)** — a push warning "cannot lock ref
|
||||
'refs/heads/main': is at X but expected Y" (server-side compare-and-
|
||||
swap rejection, `incorrect old value provided`). Reproduced locally:
|
||||
two concurrent plain pushes of the *same* ref from the same base make
|
||||
the loser fail exactly this way — the app can race itself when two
|
||||
push sources for one repository run at once (two panels of the same
|
||||
repo, or the banner Push racing the header's Republish; each guard was
|
||||
per-view only). Fix: pushes are now single-flight per repository in
|
||||
`Backend::push_repo_from` via an `Arc<Mutex<HashSet<RepoAddr>>>` guard
|
||||
(`PushGuard`, RAII: the lock is released on completion, on error and on
|
||||
task cancellation alike); a second concurrent push fails fast with
|
||||
"A push to this repository is already in progress" instead of racing.
|
||||
Racing an external `git push` against the same server remains possible
|
||||
(benign: the ref converges; the loser logs a warning only).
|
||||
- **Fix (after e2e, user report)** — after a successful push the
|
||||
repository panel's commit list stayed on the old commit (even across
|
||||
restarts): the browser reads the GitCache mirror, and a fetch never
|
||||
moves a mirror's *local* branches — `origin/main` advanced while local
|
||||
`main` (what the commit list walks) stayed behind. ngit/nak never hit
|
||||
this because they operate on real clones the user `git pull`s; nak also
|
||||
publishes the updated 30618 state *before* each push, which Signed
|
||||
already did. Fixes, mirroring a `git pull --ff-only` on the browser
|
||||
clone: new `signed_git::fast_forward_branches(workdir)` (per local
|
||||
branch, when it is an ancestor of its `refs/remotes/origin/*`
|
||||
counterpart: the checked-out branch is merged so its worktree follows,
|
||||
dirty worktrees and local-only commits are never touched; returns
|
||||
whether anything moved); `RepoDetailView::load_repo`'s background
|
||||
refresh fast-forwards after `fetch_all` and rebuilds the explorer,
|
||||
previews and commit list (`reload_worktree`) when anything moved;
|
||||
`push_unpushed_checkout` reloads the mirror on success so an owned
|
||||
repo's pushed commit appears immediately; `ensure_origin` now also
|
||||
configures the standard `remote.origin.fetch` refspec (create-flow
|
||||
mirrors otherwise never map heads on fetch). New test
|
||||
`fast_forward_branches_moves_the_mirror_and_keeps_local_work`; the
|
||||
remote-only-branch limitation stays (a branch the mirror has never
|
||||
checked out is not listed), as documented.
|
||||
@@ -1,49 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Fork support
|
||||
|
||||
- [x] Fork badge on repo list cards (`repo_list.rs::render_card`).
|
||||
- [x] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog.
|
||||
- [x] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper).
|
||||
|
||||
## Pull request improvement
|
||||
|
||||
### New pull request panel (replaces the dialog)
|
||||
|
||||
- [x] "New pull request" (PR list header + repo header `New PR`) opens a center panel instead of the paste dialog:
|
||||
- [x] Base/compare branch selectors fed from a user-chosen local checkout (GitHub-style; defaults: announced HEAD for base, checkout's current branch for compare).
|
||||
- [x] Files/Commits tabs like the repo panel: diff of `merge-base..compare` (shared `DiffPane` widget, also extracted for the commit diff panel) + virtual commit list with count badge; clicking a commit opens its diff panel.
|
||||
- [x] Only two inputs: title (required, gates the Create button) and description (optional).
|
||||
- [x] Patch is generated from the checkout at submit time (`format_patch_between` on the stored merge base); panel closes after publishing, errors surface in the PR list banner.
|
||||
- [x] Removed with the dialog: paste textarea, draft checkbox, branch-name input and the mirror-clone apply-check hint (store behavior unchanged: `open_pull_request` still publishes the series + `branch-name`/`merge-base`/`r` tags and pushes the tip).
|
||||
|
||||
### Send patch panel (classic paste flow)
|
||||
|
||||
- [x] "Send patch" entry in the repo header PRs dropdown (`RepoAction::SendPatch`) and a "New pull request ▾ Send patch" dropdown replacing the PR list's plain new-PR button.
|
||||
- [x] `send_patch.rs` center panel: title + optional description + `git format-patch` paste area; submits through `RepoStore::open_pull_request` (no checkout, no `branch-name`/`merge-base`). Synchronous store errors (malformed/oversized patch, sign-in) keep the panel open with an inline error; the panel closes once the publish is underway.
|
||||
|
||||
- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog (dialog since replaced by the panel above).
|
||||
- [x] P1: `RepoStore::update_pull_request` (kind 1619 + root-revision patch) with an author-only "Update" button on the PR detail header.
|
||||
- [x] P1: `latest_update` filters by PR author.
|
||||
- [x] P2: local checkout picker in the new-PR dialog (folder picker + source/target branches + Generate): `signed_git::{merge_base, format_patch_between, patch_applies}`; `merge-base` tag now published; best-effort apply check shown under the patch field (superseded by the panel's live compare view).
|
||||
- [x] P3: push tip to grasp servers under `refs/nostr/<event-id>` before publishing (from the local checkout); multi-commit series published as NIP-10-chained 1617 events with a 60 KB per-patch cap; PR list shows dismissible error/warning banners (incl. push failures).
|
||||
- [x] P4: merge status tags — `merge_pull_request` publishes 1631 with `applied-as-commits` + `r` per applied commit and `q`/`e`-reply tags per applied patch event.
|
||||
|
||||
### Pull request follow-ups
|
||||
|
||||
- [ ] GRASP-06 `/prs/<npub>/<id>.git` contributor endpoints + kind-10317 user grasp-list fallback.
|
||||
- [ ] Merge button in the PR detail view (`merge_pull_request` is store-only today), then fetch-and-merge (`merge-commit`) when the push backend is guaranteed.
|
||||
- [ ] Local-checkout generation for the update-PR dialog (currently paste-only); once it lands, push update tips to the same `/prs/` set under the PR's stable ref.
|
||||
- [x] Fork-aware compare in the New PR panel: the compare side can come from an announced fork repository (u-tag/EUC-related, own forks first) whose branches are imported into the base repo's GitCache mirror under `refs/fork/<owner>/<id>/*`; `merge-base`/diff/`format-patch`/push all run in the mirror against full refs. The panel's source picker switches between local checkouts and announced forks.
|
||||
- [x] GRASP-06 author hosting: PR tips are pushed to the author's own grasp servers under `/prs/<author-npub>/<repo-id>.git` (kind-10317 grasp list, settings defaults as fallback) before the base announcement's servers; the `clone` tag carries the `/prs/` URLs first.
|
||||
- [ ] Checkout suggestions ("ready to contribute"): remembered/matched local checkouts prefill the New PR panel; a repo-panel banner suggests creating a PR when a branch is ahead with no open PR. Sidebar "Ready to contribute" group is v2 (deferred).
|
||||
|
||||
## Performance: render path
|
||||
|
||||
- [ ] Virtualize issue/PR comment threads (`issue_detail.rs::render_comments`, `pull_request_detail.rs::render_comments`). Harder than the list tabs: comment cards have variable heights and live inside a scrolling page together with the body and the comment form, so this needs either measured item sizes or restructuring the whole discussion tab into one virtual list. (Comment bodies are already cached as `SharedString`, so re-renders are cheap element constructions, not byte copies.)
|
||||
|
||||
## Performance: relay/subscription behavior
|
||||
|
||||
- [ ] Narrow `RepoStore`'s `BackendEvent::NostrUpdate` relevance filter (`crates/signed_state/src/repo.rs:65-98`): any comment/status/label/deletion from anywhere wakes every open repo store; match only events referencing this repo's roots or coordinate.
|
||||
- [ ] Reconsider `ban_relay_on_mismatch(true)` (`crates/signed_nostr/src/backend.rs:49`): combined with many short-lived auto-close subscriptions, a late event after EOSE can permanently ban a relay for the session.
|
||||
- [ ] Relays added for a repo stay in the pool forever and grow unboundedly (`crates/signed_state/src/backend.rs`); consider removing repo relays when the last panel for that repo closes.
|
||||
Reference in New Issue
Block a user