refactor
This commit is contained in:
+17
-5
@@ -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,12 +11,21 @@ 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.);
|
||||
|
||||
|
||||
@@ -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.);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
@@ -708,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.
|
||||
@@ -797,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 {
|
||||
@@ -903,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;
|
||||
@@ -952,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 })
|
||||
@@ -1040,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> {
|
||||
@@ -1058,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()?
|
||||
@@ -1093,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)
|
||||
}
|
||||
@@ -1667,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`.
|
||||
@@ -1675,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),
|
||||
}
|
||||
@@ -1709,7 +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)?)
|
||||
repo_branches(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||
@@ -1770,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.
|
||||
@@ -1791,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)?,
|
||||
|
||||
@@ -160,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.
|
||||
@@ -180,7 +188,7 @@ 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| cx.notify())?;
|
||||
@@ -208,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)?,
|
||||
_ => {
|
||||
@@ -906,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(());
|
||||
@@ -932,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,
|
||||
@@ -964,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| {
|
||||
@@ -984,7 +992,7 @@ 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: Vec<Event> = client
|
||||
.fetch_events(filters::grasp_list(public_key))
|
||||
@@ -1068,7 +1076,7 @@ impl Backend {
|
||||
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
self.push_task(task);
|
||||
}
|
||||
|
||||
/// Add relays and connect to them.
|
||||
@@ -1083,7 +1091,7 @@ 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| cx.notify())?;
|
||||
@@ -1125,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.
|
||||
@@ -1145,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())))?;
|
||||
}
|
||||
@@ -1168,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() {
|
||||
@@ -1201,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!(
|
||||
@@ -1287,7 +1295,7 @@ impl Backend {
|
||||
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()));
|
||||
@@ -1313,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}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -67,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.
|
||||
@@ -79,7 +78,7 @@ 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.
|
||||
@@ -129,19 +128,19 @@ 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(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscriptions: subscriptions,
|
||||
@@ -155,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") {
|
||||
@@ -243,7 +250,7 @@ impl CheckoutsStore {
|
||||
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.
|
||||
@@ -345,7 +352,7 @@ 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(_) => {
|
||||
@@ -357,9 +364,9 @@ impl CheckoutsStore {
|
||||
};
|
||||
|
||||
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.refresh.finish()
|
||||
@@ -386,7 +393,7 @@ impl CheckoutsStore {
|
||||
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()));
|
||||
|
||||
@@ -23,7 +23,12 @@ use signed_nostr::new_backend;
|
||||
/// 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,29 +289,44 @@ 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) {
|
||||
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()
|
||||
.kind(Kind::Metadata)
|
||||
@@ -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}"),
|
||||
}
|
||||
|
||||
@@ -129,6 +129,14 @@ impl RepoListStore {
|
||||
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);
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
@@ -170,7 +178,7 @@ impl RepoListStore {
|
||||
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.
|
||||
@@ -291,7 +299,7 @@ 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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -5,8 +5,10 @@ use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::TreeItem;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
@@ -365,6 +367,44 @@ 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()
|
||||
@@ -459,14 +499,7 @@ pub(super) fn sidebar_section(
|
||||
}
|
||||
|
||||
/// The comments on a root event, issue or PR, one card per comment.
|
||||
///
|
||||
/// Comment bodies become shared strings once per comment, not per render.
|
||||
pub(super) fn comments_section(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
contents: &mut HashMap<EventId, SharedString>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
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()));
|
||||
@@ -479,10 +512,7 @@ pub(super) fn comments_section(
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = contents
|
||||
.entry(comment.id)
|
||||
.or_insert_with(|| SharedString::from(comment.content.clone()))
|
||||
.clone();
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
@@ -22,7 +20,6 @@ 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,
|
||||
@@ -43,7 +40,6 @@ impl IssueDetailView {
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
contents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,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() {
|
||||
let content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
}
|
||||
})
|
||||
.clone();
|
||||
};
|
||||
|
||||
(
|
||||
activity_subject(issue),
|
||||
@@ -172,12 +162,7 @@ impl Render for IssueDetailView {
|
||||
)
|
||||
.child(div().text_sm().child(content)),
|
||||
)
|
||||
.child(comments_section(
|
||||
&self.store,
|
||||
issue_id,
|
||||
&mut self.contents,
|
||||
cx,
|
||||
))
|
||||
.child(comments_section(&self.store, issue_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
|
||||
@@ -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,
|
||||
@@ -110,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,14 +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::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||
use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||
|
||||
mod about;
|
||||
mod browser;
|
||||
@@ -55,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;
|
||||
@@ -106,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 {
|
||||
@@ -193,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>>>,
|
||||
@@ -346,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,
|
||||
@@ -922,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1056,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1072,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1232,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.
|
||||
@@ -1422,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)
|
||||
@@ -1432,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();
|
||||
@@ -2165,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)
|
||||
@@ -2241,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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -2255,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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -2492,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;
|
||||
|
||||
@@ -17,7 +16,6 @@ use gpui_component::input::{Textarea, TextareaState};
|
||||
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::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
@@ -26,7 +24,7 @@ use nostr::prelude::{Event, EventId, Kind, Nip34Tag};
|
||||
use signed_core::{activity_subject, pull_request_patch};
|
||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
@@ -68,8 +66,6 @@ pub struct PullRequestDetailView {
|
||||
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>>>,
|
||||
}
|
||||
@@ -110,7 +106,6 @@ impl PullRequestDetailView {
|
||||
pane,
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
contents: HashMap::new(),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -315,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()
|
||||
@@ -410,12 +397,7 @@ impl PullRequestDetailView {
|
||||
this.child(div().text_sm().child(self.description.clone()))
|
||||
}),
|
||||
)
|
||||
.child(comments_section(
|
||||
&self.store,
|
||||
root_id,
|
||||
&mut self.contents,
|
||||
cx,
|
||||
))
|
||||
.child(comments_section(&self.store, root_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
|
||||
@@ -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,
|
||||
@@ -125,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -153,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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");
|
||||
|
||||
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
# PLAN — Codebase audit: over-engineering, dead code & simplification
|
||||
|
||||
> **Status (2026-09-04): proposed.** Full-repo audit (~27k lines, 13 crates).
|
||||
> Every finding was cross-checked against consumers and verified against the
|
||||
> locked library sources (gpui-component `18922d6`, rust-nostr `472c883`,
|
||||
> gix 0.87.1). Pick the steps you want before processing; ordered
|
||||
> safest-first. Estimated total: **~3,000+ lines removable (~12%)**.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Zero-risk deletions (~700 L, pure removals)
|
||||
|
||||
1. **Unused Cargo deps** (verified by grep):
|
||||
- `desktop`: `gpui_linux`, `gpui_windows`, `gpui_macos` (only
|
||||
`gpui_platform` is used — it's the meta-crate that picks the backend),
|
||||
`log`
|
||||
- `workspace`: `chrono`
|
||||
- `signed_nostr`: `signed_core`, `nostr`
|
||||
- `assets`: `log`
|
||||
2. **`signed_core::clone_url` module** (whole file) — only self-tests
|
||||
reference it; `repo_detail/mod.rs:2418` *produces* the format but never
|
||||
parses it. (`clone_url.rs` + `lib.rs:13`) <- acceptable
|
||||
3. **`signed_core::comments` module** (`CommentThread`, `comment_threads`) —
|
||||
no consumers; SDK has `nip22::extract_parent` anyway.
|
||||
(`comments.rs` + `lib.rs:14`)
|
||||
4. **`paths` dead accessors**: `cache_dir()`, `logs_dir()`, `keymap_file()`,
|
||||
`set_custom_data_dir()` + `CUSTOM_DATA_DIR`/`OnceLock` machinery
|
||||
(`paths/src/lib.rs:13,50-61,112-169`). Used: `desktop_dir`,
|
||||
`documents_dir`, `settings_file`, `nostr_dir`, `repos_dir`.
|
||||
5. **`signed_git::patch_applies`** (+test) — own TODO says superseded by the
|
||||
live compare view (`lib.rs:252-277, 2294-2324`).
|
||||
6. **`signed_git::worktree_tags`** — both UI sites call `repo_tags` directly
|
||||
(`lib.rs:1811-1813`).
|
||||
7. **Unused re-exports**: `workspace::image_cache` (`workspace/src/lib.rs:6`);
|
||||
`signed_state::lib`'s `pub use utils::shorten_pubkey`
|
||||
(`signed_state/src/lib.rs:21`).
|
||||
8. **Sidebar placeholder nav items** "Inbox", "Search", "Guide" — all three
|
||||
just open the Explore panel (`sidebar/mod.rs:578-618`). <- acceptable
|
||||
9. **`import_dialog.rs`** — opens an empty 400px dialog; the "Import identity"
|
||||
sidebar button is a dead end (`sidebar/mod.rs:405-408,493-504`). <- acceptable
|
||||
10. **All `wasm32` cfg paths + `nostr-memory` dep** — GPUI has no wasm
|
||||
backend; the target cannot link (`signed_state/src/lib.rs:52-63`,
|
||||
`checkouts.rs:108-165`, `signed_nostr/src/backend.rs:9-29`,
|
||||
`signed_nostr/Cargo.toml`). <- acceptable, note: GPUI have support for wasm via gpui_web, updated your memory or check before make changes
|
||||
11. **`dock::t()` unreachable `"Dock.Unnamed"` arm** (`dock/src/lib.rs:21-30`).
|
||||
12. Two handler-less `Button`s ("user" with fake `dropdown_caret`,
|
||||
"maintainers") — render as plain `h_flex` or wire real menus
|
||||
(`sidebar/mod.rs:426-434`, `repo_detail/mod.rs:2289-2307`). <- acceptable
|
||||
13. Stale doc references to nonexistent `helpers::track`
|
||||
(`diff.rs:328`, `mod.rs:201-203`).
|
||||
|
||||
## Step 2 — Dead feature removal (~500 L)
|
||||
|
||||
1. **Login/logout API** — `login`, `login_with_new_identity`,
|
||||
`login_with_nsec`, `login_with_bunker`, `logout`, `with_master_key`
|
||||
(`backend.rs:879-988,1537-1542`). UI only uses `create_identity` + keyring
|
||||
restore. ⚠️ `login_with_new_identity` stores unencrypted nsec — security
|
||||
footgun. **Keep** `extract_master_key` + the `bunker://` branch of
|
||||
`restore_session` (services older keyring entries). <- acceptable
|
||||
2. **`RepoStore::merge_pull_request` + `publish_applied_status`** — no merge
|
||||
action exists in the UI (`repo.rs:1122-1256`). <- acceptable
|
||||
3. **`RepoStore::publish_state`** — superseded by `Backend::push_repo_from`
|
||||
(`repo.rs:1070-1120`).
|
||||
4. **Annotation machinery** — `cover_note_of`/`labels_of`/`subject_of` (no
|
||||
callers) + `cover_notes`/`labels` fields + per-root DB query loop +
|
||||
`annotations_for` relay fetch. **Every kind-1624/1985 event currently
|
||||
triggers a full refresh of every open RepoStore for data nothing
|
||||
displays** (`repo.rs:52-56,339-362,489,531-563`).
|
||||
5. **`BackendEvent::Error` variant + `emit_error` + ~16 emission sites** —
|
||||
matched nowhere (consumers only match `SignerChanged`, `SignerRequired`,
|
||||
`PassphraseRequired`, `NostrUpdate`, `Synced`, `SyncProgress`,
|
||||
`Published`) (`backend.rs:64,67-74,1047-1050`). <- acceptable
|
||||
6. **`BackendEvent::Connected` + `connected` field + `is_connected()`** —
|
||||
emitted twice, never consumed (`backend.rs:49,83,154,1110-1111,1052-1055`).
|
||||
7. **`sync_progress` field + getter + `SyncProgress::channel` watch-loop
|
||||
math** — payload discarded; used only as a dumb refresh tick
|
||||
(`backend.rs:84,1057-1060,1227-1258`). <- acceptable
|
||||
8. **`Backend::subscribe`, `add_discovery_relays`, `publish_announcement`** —
|
||||
zero callers (`backend.rs:1123-1158,1388-1395`).
|
||||
9. **Write-only fields**: `RepoStore::refs` (`repo.rs:37-38,449`),
|
||||
`RepoListStore::set_author` (`repo_list.rs:135-140`),
|
||||
`Update::event_id` (`signed_nostr/src/update.rs:10`).
|
||||
10. **NIP-44 half of `UniversalSigner`** (`AsyncNip44` bounds,
|
||||
encrypt/decrypt plumbing, ~60 of 200 L) — app never touches DMs
|
||||
(`signed_nostr/src/signer.rs`). <- acceptable
|
||||
|
||||
## Step 3 — Library swaps (~600 L)
|
||||
|
||||
1. **`signed_ui::DropdownButton` → `gpui_component::button::DropdownButton`**
|
||||
— present in the locked revision, same `new/button/dropdown_menu` surface
|
||||
(the local doc even says it matches). Migrate 3 call sites
|
||||
(`pull_requests.rs`, `repo_detail/mod.rs`), delete the ~200 L file. <- acceptable
|
||||
2. **`wire_number_input` → `SettingField::number_input`**
|
||||
(`settings_dialog.rs:691-761`, ~90 L) — you already import
|
||||
`NumberFieldOptions` from gpui-component's setting module. <- acceptable
|
||||
3. **Custom C-unquoting → `gix::quote::ansi_c::undo`**
|
||||
(`signed_git/src/lib.rs:1610-1683` + call sites, ~100 L) — already in the
|
||||
dep tree, octal/escape semantics identical. Keep tests as regression tests.
|
||||
4. **`ensure_origin` redundant refspec write** (`signed_git/src/lib.rs:523-530`)
|
||||
— `git remote add` creates `remote.origin.fetch` by default.
|
||||
5. **Two hand-rolled tab bars → gpui-component `TabBar`**
|
||||
(`repo_detail/mod.rs:2120-2265`, `new_pull_request.rs:1236-1305`, ~110 L) —
|
||||
`pull_request_detail.rs` already uses `TabBar` correctly (proves the fit). <- acceptable
|
||||
6. **CLI `merge_base` → `gix::Repository::merge_base`**
|
||||
(`signed_git/src/lib.rs:201-222`) — the codebase already uses the gix one
|
||||
in `pull_request_detail.rs:244`.
|
||||
7. **`image_cache` → `gpui::retain_all` or single-map LRU** — vendored copy
|
||||
with a `max_items` param every call site passes `MAX_IMAGES=128` to
|
||||
(`signed_ui/src/image_cache.rs`, 139 L). Delete the param + dual-structure
|
||||
LRU or use upstream unbounded cache.
|
||||
|
||||
## Step 4 — Dedup passes (~1,200 L)
|
||||
|
||||
1. **`PullRequestDetailView` → hold `Entity<DiffPane>`** instead of its ~200 L
|
||||
inline copy of `diff.rs` (`pull_request_detail.rs:310-552`). The other two
|
||||
consumers already do this.
|
||||
2. **Issue/PR detail shared sections** (~300 L): comments list, comment form,
|
||||
participants/labels sidebar, `sidebar_title` — extract into `helpers.rs`
|
||||
(`issue_detail.rs` vs `pull_request_detail.rs`).
|
||||
3. **Dialog scaffolding helper** (~150-200 L): 4 copies of `{busy, error}`
|
||||
state structs, verbatim error rows, identical
|
||||
`cx.spawn → close_dialog / show error` plumbing
|
||||
(`onboarding_dialog.rs`, `passphrase_dialog.rs`, `create_repo_dialog.rs`,
|
||||
`init_dialog.rs`, +2 more sites).
|
||||
4. **Triplicated debounce state machine → one helper** — `refreshing` /
|
||||
`refresh_dirty` / `debouncing` trio copied into `repo.rs`, `checkouts.rs`,
|
||||
`repo_list.rs` (~105 L).
|
||||
5. **signed_git helpers**: `push_main`/`push_all` twin bodies → one
|
||||
`push_refspecs`; git-CLI spawn boilerplate → one `git_output`;
|
||||
grasp→https rewrite → one fn; "try each mirror URL" loop → one helper.
|
||||
6. **grasp-list parsing ×3 → one helper** — `backend.rs:996-1012`,
|
||||
`backend.rs:1586-1618`, `grasp_servers.rs:240-265`.
|
||||
7. Smaller copies: ref-selector trigger ×2, count badge ×3 (use
|
||||
`signed_ui::CountBadge`), grasp-server editor duplicated in
|
||||
`settings_dialog.rs:408-614` vs `grasp_servers.rs` (~90 L), folder-picker
|
||||
prompts ×4, "add panel to dock Center" ×10, fork-label upstream lookup ×2,
|
||||
avatar+name row ×9 (→ one `user_row` helper in signed_ui).
|
||||
8. **`Backend::send` vs `publish_event`** — copy-pasted bodies differing only
|
||||
in `finalize_async` (`backend.rs:1293-1386`).
|
||||
|
||||
## Step 5 — Structural (do deliberately)
|
||||
|
||||
1. **`UniversalSigner` → enum** — verified: nostr-sdk 0.45 `Client` has no
|
||||
signer slot (external `SignerAuthenticator` by value at build time), so a
|
||||
swap-in-place wrapper IS needed — but only `Keys`/`NostrConnect` ever
|
||||
occur. Replace 200-L vtable (`InnerSigner` trait + `InnerSignerImpl<T>` +
|
||||
custom error + boxed futures) with
|
||||
`enum Signer { Keys(Keys), Connect(NostrConnect) }` in
|
||||
`Arc<RwLock<Signer>>` (~40 L). <- acceptable
|
||||
2. **`GitStore` single install** — `signed_state::init` installs empty root
|
||||
(`lib.rs:44`), immediately replaced by `desktop/main.rs:81`. Pass the root
|
||||
into `init`.
|
||||
3. **Error handling in signed_git**: `map_err(|e| anyhow!("{e}"))` → plain
|
||||
`?` (preserves source chain; `lib.rs:1787,1798,1845,1852`); blanket
|
||||
`.ok()`/`.unwrap_or_default()` → matched cases
|
||||
(`lib.rs:919,995,1761,364,682,1779`).
|
||||
4. **`ProfileStore` second flume channel → `WeakEntity` + `update()`**
|
||||
(~25 L; the results channel only exists to get back to main thread).
|
||||
5. **Unbounded `tasks` Vec growth** — only `repo.rs` prunes; `backend.rs`
|
||||
(~18 push sites), `checkouts.rs` (grows every 15-60 s poll cycle),
|
||||
`profile.rs` (2 per metadata event) accumulate finished handles forever.
|
||||
One-line `retain(|t| !t.is_ready())` per store.
|
||||
6. **Redundant `observe → cx.notify()` subscriptions**
|
||||
(`sidebar/mod.rs:81-88`, `repo_detail/mod.rs:354-355,1914-1921`) — this
|
||||
gpui revision auto-tracks entities read during render, so re-render-only
|
||||
observers are belt-and-suspenders. ⚠️ Verify before deleting; see Step 7.2.
|
||||
7. **`open_upstream` sleep-poll → `cx.observe`** (`repo_detail/mod.rs:1113-1152`)
|
||||
— 60×250 ms race-prone loop re-implementing the store's notify mechanism.
|
||||
8. **Checkouts map `Arc` removal** — accessors deep-clone anyway
|
||||
(`checkouts.rs:69-81`).
|
||||
9. Minor: `CheckoutRecord` manual `Default` → derive; `dock` re-export trim
|
||||
(27 items, ~10 used); `PixelAvatar.size` field with no setter → const;
|
||||
`SCAN_SKIPPED_DIRS` 1-element array → direct compare; `RepoAction` menu
|
||||
indirection → `on_click`; `Announcement::from_event` round-trip after
|
||||
building from typed data.
|
||||
|
||||
## Step 6 — Over-optimization (optional, unmeasured machinery)
|
||||
|
||||
1. **`HeaderCache`** (`repo_detail/mod.rs:113-123,1426-1461`) — keyed
|
||||
invalidation + 3 `Rc` layers memoizing 2 bech32 encodes + 2 `format!`s
|
||||
(~45 L). Compute inline.
|
||||
2. **Comment-body memoization** `contents: HashMap<EventId, SharedString>`
|
||||
(`issue_detail.rs`, `pull_request_detail.rs`) — caches one small alloc per
|
||||
render; convert inline.
|
||||
3. **`OBJECT_CACHE_BYTES = 64 MiB`** indiscriminately applied — scope to
|
||||
history-walk entry points or drop (`signed_git/src/lib.rs:770,859-863`).
|
||||
4. **`file_commit(…, include_description: bool)`** — boolean flag saving one
|
||||
alloc; split into named constructors or always include.
|
||||
5. **`signed_core` could drop its `gpui` dep** if `Announcement`'s
|
||||
`SharedString` fields became `String` (`model.rs:9-35`).
|
||||
6. **`build_state`** raw `Tag::parse(...).expect()` → typed `Nip34Tag::to_tag`
|
||||
(`signed_core/src/state.rs:7-18`).
|
||||
7. **`PixelAvatar` FNV/RNG stack** (~60 L) — deterministic-hashing requirement
|
||||
is legit; reconsider if `DefaultHasher` stability is acceptable.
|
||||
|
||||
## Step 7 — Clean verdicts + one correctness note (no action)
|
||||
|
||||
1. **Confirmed clean, keep as-is**: `signed_core` correctly uses
|
||||
`Nip34Tag::parse` / SDK builders (only the `u` tag is hand-parsed — SDK
|
||||
doesn't model it); `dock` crate is a genuine thin skin over
|
||||
`gpui_base::dock` renderer traits (keep; only trim re-exports); LMDB is
|
||||
justified (instant startup lists); `signed_git` test suite + `tempfile`
|
||||
healthy. Gossip/NIP-65 machinery serves only one login query — *consider*
|
||||
dropping if NIP-65 routing isn't on the roadmap.
|
||||
2. **Correctness note**: `IssuesView` / `PullRequestsView` /
|
||||
`IssueDetailView` / `PullRequestDetailView` never observe their
|
||||
`RepoStore` — refresh only works because this gpui revision auto-tracks
|
||||
render reads. Accidental; pinning a different gpui breaks them silently.
|
||||
Relevant to Step 5.6.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of operations
|
||||
|
||||
1. Step 1 + Step 2 first — pure deletions, verify with `cargo check` +
|
||||
`cargo test`.
|
||||
2. Step 3 (library swaps) next — mechanical, mostly call-site migrations.
|
||||
3. Step 4 (dedup) — largest line savings.
|
||||
4. Step 5 (structural) — deliberate, one at a time.
|
||||
5. Step 6 (over-optimization) — optional, only if you want the extra ~110 L.
|
||||
|
||||
Each step below is self-contained; tick what you want done.
|
||||
|
||||
- [ ] Step 1 — zero-risk deletions
|
||||
- [ ] Step 2 — dead feature removal
|
||||
- [ ] Step 3 — library swaps
|
||||
- [ ] Step 4 — dedup passes
|
||||
- [ ] Step 5 — structural changes
|
||||
- [ ] Step 6 — over-optimization cleanup
|
||||
@@ -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