improve document and comment

This commit is contained in:
2026-08-31 16:11:34 +07:00
parent d2468545d6
commit 8dc45d08c0
21 changed files with 155 additions and 300 deletions
+12 -19
View File
@@ -27,11 +27,7 @@ use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle}; use crate::{TAB_BAR_HEIGHT, panel_handle};
/// What every part of the skin reads, and the dock area it belongs to. /// What every part of the skin reads, and the dock area it belongs to.
/// /// Shared by reference with the per-container renderers.
/// The renderer is the only skin-owned object in the picture, so the settings
/// the old `DockArea` carried live here. It is shared by reference with the
/// per-container renderers, which are built once each and outlive any one
/// frame.
pub(crate) struct SkinShared { pub(crate) struct SkinShared {
area: WeakEntity<DockArea>, area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>, toggle_button_visible: Cell<bool>,
@@ -201,10 +197,9 @@ impl DockAreaRenderer for SignedDockSkin {
.into_any_element() .into_any_element()
} }
/// The "unknown panel" message the old `InvalidPanel` drew. /// The "unknown panel" message the old `InvalidPanel` drew. It answers
/// /// `dump` with the state it was handed, so a layout written by a build
/// It answers `dump` with the state it was handed, so a layout written by /// that knows the panel survives a load and save here.
/// a build that knows the panel survives a load and save here.
fn build_placeholder( fn build_placeholder(
&self, &self,
state: &PanelState, state: &PanelState,
@@ -246,12 +241,10 @@ impl SignedDockSkin {
} }
} }
/// Turns the window's mouse stream into dock resizing. /// Turns the window's mouse stream into dock resizing. A resize is driven
/// /// by pointer moves anywhere in the window, so this paints nothing and
/// A resize is driven by pointer moves that land anywhere in the window, not /// exists for its `paint` hook — the only place a window-level mouse
/// only on the handle, so it cannot be expressed as a listener on the handle /// listener can be registered.
/// itself. This element paints nothing and exists for its `paint` hook, which
/// is the only place a window-level mouse listener can be registered.
struct DockResizeTracker { struct DockResizeTracker {
dock: DockContext, dock: DockContext,
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
@@ -317,10 +310,10 @@ impl Element for DockResizeTracker {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return; return;
} }
// Dragging a closed dock's handle reopens it, as the old dock // Dragging a closed dock's handle reopens it. The live
// did. The live state is read rather than the render-time // state is read rather than the render-time snapshot in
// snapshot in `dock`, which would still say closed for the // `dock`, which would still say closed for the rest of the
// rest of the frame and toggle it shut again on the next move. // frame and toggle it shut again on the next move.
let open = shared let open = shared
.area() .area()
.upgrade() .upgrade()
+4 -6
View File
@@ -7,12 +7,10 @@ use gpui_component::ActiveTheme as _;
use crate::Panel; use crate::Panel;
/// Stands in for a panel this build cannot construct — one whose `panel_name` /// Stands in for a panel this build cannot construct. It reports the
/// no [`PanelRegistry`](gpui_base::dock::PanelRegistry) builder answers to. /// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so
/// /// a layout written by a build that knows the panel survives a load and
/// It reports the original [`PanelState`] from /// save here.
/// [`dump`](gpui_base::dock::Panel::dump), so a layout written by a build that
/// knows the panel survives a load and a save here rather than losing it.
pub(crate) struct InvalidPanel { pub(crate) struct InvalidPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
+5 -7
View File
@@ -1,12 +1,10 @@
//! The Signed dock skin. //! The Signed dock skin.
//! //!
//! The dock engine lives upstream: `gpui_base::dock` owns the layout tree, //! The dock engine lives upstream (`gpui_base::dock` owns the layout tree,
//! the drags, the zoom and the persistence, and `gpui_component::dock` //! drags, zoom and persistence); this crate is the appearance the app used
//! supplies the default appearance. This crate is the appearance the app //! to vendor from gpui-component — a 44px tab bar that doubles as the
//! used to vendor from gpui-component — a 44px tab bar that doubles as the //! window title bar, with pill tabs, window controls, title-bar dragging
//! window title bar, with pill tabs, window controls, title-bar dragging and //! and previous/next tab buttons.
//! previous/next tab buttons — re-implemented against upstream's renderer
//! traits.
//! //!
//! Everything `gpui_component::dock` exports is re-exported here, so the app //! Everything `gpui_component::dock` exports is re-exported here, so the app
//! keeps importing the dock from a single place. //! keeps importing the dock from a single place.
+23 -37
View File
@@ -84,11 +84,9 @@ impl Render for DragPanelPreview {
} }
/// Where the zoom affordance goes for the group's displayed panel, or `None` /// Where the zoom affordance goes for the group's displayed panel, or `None`
/// when there is none to offer. /// when there is none to offer. Both [`Panel::zoom_control`] (where) and
/// /// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
/// Two questions, and both have to be asked. [`Panel::zoom_control`] says /// zoom that fails the latter.
/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
/// whether zooming happens at all, and base refuses a zoom that fails it.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> { fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?; let panel = group.active_panel()?;
panel panel
@@ -122,11 +120,8 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
} }
} }
/// One tab group's appearance. /// One tab group's appearance. Built once per container, so the tab bar's
/// /// scroll position and measured title-bar geometry belong to the group.
/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once
/// per container — so the tab bar's scroll position and the measured
/// title-bar geometry belong to the group they describe.
pub(crate) struct SignedTabGroupSkin { pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
@@ -177,9 +172,8 @@ impl SignedTabGroupSkin {
/// The bottom or right dock whose root tab group this group is, if any. /// The bottom or right dock whose root tab group this group is, if any.
/// ///
/// Base bars a dock's only group from being dragged or closed, so the /// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be /// dock cannot be emptied — but a bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that /// closable, so the skin routes around the bar for these groups.
/// — so the skin recognizes the group and routes around the bar.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> { fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?; let area = self.shared.area().upgrade()?;
let area = area.read(cx); let area = area.read(cx);
@@ -270,9 +264,7 @@ impl SignedTabGroupSkin {
} }
/// The previous/next tab buttons shown in the tab bar's leading prefix. /// The previous/next tab buttons shown in the tab bar's leading prefix.
/// /// Always rendered, disabled at the ends of the strip (or collapsed).
/// Unlike the dock toggle button they always render, but are disabled at
/// the ends of the tab strip (or while the panel is collapsed).
fn render_prev_next_tab_buttons( fn render_prev_next_tab_buttons(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -405,10 +397,9 @@ impl SignedTabGroupSkin {
) )
} }
/// One tab of the pill strip. /// One tab of the pill strip. While collapsed, tabs lose the active
/// /// style and all interactions, and the strip becomes the way a closed
/// While collapsed, tabs lose the active style and all interactions, and /// bottom dock is opened again.
/// the strip becomes the way a closed bottom dock is opened again.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_tab( fn render_tab(
&self, &self,
@@ -578,10 +569,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if group.panels().is_empty() { if group.panels().is_empty() {
return div().id("tab-panel"); return div().id("tab-panel");
} }
// Closing the only panel of a bottom/right dock would leave an empty // Closing the only panel of a bottom/right dock would leave an
// dock, which base refuses through the group. The skin removes the // empty dock, which base refuses; the skin removes the dock instead.
// whole dock instead — the vendored dock's close took its split
// group away just the same.
let dock_to_remove = (group.panels().len() <= 1) let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx)) .then(|| self.is_dock_root_group(group, cx))
.flatten(); .flatten();
@@ -601,8 +590,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
this.on_action({ this.on_action({
let group = group.clone(); let group = group.clone();
move |_: &ToggleZoom, window, cx| { move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel that // The affordance decides the control, so a panel
// offers none is not zoomed *in* by the keybinding // offering none is not zoomed *in* by the keybinding
// either. Zooming out is never refused: a panel that // either. Zooming out is never refused: a panel that
// stopped offering the control while zoomed would // stopped offering the control while zoomed would
// otherwise strand the user with no way back. // otherwise strand the user with no way back.
@@ -671,12 +660,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some(); let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. Only // macOS: the traffic lights overlay the window's top-left corner.
// the group whose tab bar actually sits under them must reserve the // Only the group whose tab bar actually sits under them reserves the
// space: the left dock (sidebar) normally clears them, and when it is // space the center's left-most, top-most group when the left dock
// closed or absent it is the center's left-most, top-most tab group // is closed or absent.
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
let needs_traffic_light_padding = cfg!(target_os = "macos") let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| { && self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx); let area = area.read(cx);
@@ -703,11 +690,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
self.scroll_handle.scroll_to_item(visible_ix); self.scroll_handle.scroll_to_item(visible_ix);
} }
// The tab strip lays out its scrollable content at content width, so // The tab strip lays out at content width, so the area after the
// the area after the last tab only spans `min_w_16` — the rest of the // last tab has no element. Cover that dead zone (last tab's right
// tab bar has no element at all. Cover that dead zone with a // edge to suffix's left edge) with a measured overlay so the whole
// measured overlay so the whole non-interactive area can drag the // non-interactive area can drag the window.
// window. Its span is [last tab's right edge, suffix's left edge].
let drag_overlay = match ( let drag_overlay = match (
self.title_bar_bounds.get(), self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(), self.title_bar_strip_bounds.get(),
+8 -13
View File
@@ -101,13 +101,11 @@ impl SignedTilesSkin {
}) })
} }
/// The trailing controls of a tile's title bar. /// The trailing controls of a tile's title bar: zoom, close and the
/// /// ellipsis menu. They use click handlers rather than the
/// A tile has no tab bar to hang a toolbar off, so this is where its zoom, /// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
/// close and ellipsis menu live. The entries use click handlers rather /// actions, which are dispatched to a focused tab group — a tile is
/// than the [`ToggleZoom`](crate::ToggleZoom) and /// not one.
/// [`ClosePanel`](crate::ClosePanel) actions: those are dispatched to a
/// focused tab group, and a tile is not one.
fn render_tile_controls( fn render_tile_controls(
&self, &self,
tile: &TileContext, tile: &TileContext,
@@ -388,12 +386,9 @@ impl TilesRenderer for SignedTilesSkin {
.size_full() .size_full()
} }
/// The canvas scrollbar. /// The canvas scrollbar. It must be an overlay: the frame is the scroll
/// /// container and base appends the tiles after it, so a scrollbar placed
/// It has to be an overlay rather than one of the frame's own children: /// inside would paint and hit-test underneath every tile.
/// the frame is the scroll container and base appends the tiles after
/// whatever the frame carries, so a scrollbar placed there would paint and
/// hit-test underneath every tile.
fn render_overlay( fn render_overlay(
&self, &self,
content: Size<Pixels>, content: Size<Pixels>,
+4 -5
View File
@@ -71,11 +71,10 @@ pub fn grasp_list(public_key: PublicKey) -> Filter {
/// NIP-22 comments (kind `1111`) referencing any of the given root events /// NIP-22 comments (kind `1111`) referencing any of the given root events
/// (issues, patches, PRs). /// (issues, patches, PRs).
/// ///
/// Comments are not addressed to the repository — they carry no `a` tag with /// Comments carry no repository `a` tag, so they must be fetched by their
/// the repo coordinate — so they must be fetched by their root reference /// root reference. NIP-22 defines the uppercase `E` tag as the thread root
/// instead. NIP-22 defines the uppercase `E` tag as the root of the thread /// (used by ngit), but some clients (including Signed) use a lowercase `e`
/// (used by ngit) while some clients (including Signed itself) reference the /// tag, so both are matched.
/// root with a lowercase `e` tag, so both are matched.
/// ///
/// Returns two filters because `#E` and `#e` conditions would be ANDed if /// Returns two filters because `#E` and `#e` conditions would be ANDed if
/// combined into one. /// combined into one.
+14 -25
View File
@@ -70,10 +70,9 @@ const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
/// Walk `root` recursively and collect the paths of git repositories /// Walk `root` recursively and collect the paths of git repositories
/// (directories containing a `.git` entry) below it. /// (directories containing a `.git` entry) below it.
/// ///
/// Hidden entries and symlinks are skipped, and directories that are /// Hidden entries and symlinks are skipped; repositories are not descended
/// themselves repositories are not descended into (so nested repositories, /// into, so nested ones (e.g. submodule worktrees) are not reported.
/// like submodule worktrees, are not reported). Results are canonicalized, /// Results are canonicalized, deduplicated and sorted.
/// deduplicated and sorted by path.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> { pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new(); let mut repos = Vec::new();
if !root.is_dir() { if !root.is_dir() {
@@ -121,11 +120,9 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
/// Clone a repository into `path` from the first working URL in /// Clone a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the /// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must /// `refs/nostr/*` PR refs like the cache clone does. The destination must
/// not exist yet; it is created by the clone. The first URL that works /// not exist yet. When no URL works, the last error is returned.
/// wins; when none do, the error of the last failing URL is returned.
/// ///
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache; /// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
/// callers open it themselves if they need a [`gix::Repository`].
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
if path.exists() { if path.exists() {
bail!("destination {} already exists", path.display()); bail!("destination {} already exists", path.display());
@@ -218,8 +215,8 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
/// `README.md` derived from `name`/`description`, and create the initial /// `README.md` derived from `name`/`description`, and create the initial
/// commit. Returns the initial commit id. /// commit. Returns the initial commit id.
/// ///
/// Uses the git CLI (like [`apply_patch`]) because it handles the plumbing /// Uses the git CLI (like [`apply_patch`]), which handles index writes,
/// (index writes, ref updates, default branch selection) natively. /// ref updates and default branch selection natively.
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> { pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
std::fs::create_dir_all(path) std::fs::create_dir_all(path)
.with_context(|| format!("failed to create {}", path.display()))?; .with_context(|| format!("failed to create {}", path.display()))?;
@@ -371,11 +368,8 @@ fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
} }
/// Map an untrusted repository id (or display name) to a safe single path /// Map an untrusted repository id (or display name) to a safe single path
/// component. /// component: everything outside `[A-Za-z0-9._-]` becomes `_`, and the
/// /// special components `.` and `..` are rejected.
/// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the
/// special components `.` and `..` so the id can't escape a directory it is
/// joined onto.
pub fn sanitize_path_component(id: &str) -> String { pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id let sanitized: String = id
.chars() .chars()
@@ -517,12 +511,9 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<Fi
} }
/// Find the most recent commit that changed `rel` (a path relative to the /// Find the most recent commit that changed `rel` (a path relative to the
/// worktree), like `git log -1 -- <rel>` does for non-merge commits. /// worktree), like `git log -1 -- <rel>`: the first commit, walking from
/// /// `HEAD` newest-first, whose tree entry for `rel` differs from its first
/// Walks history from `HEAD` newest-first and returns the first commit whose /// parent's. `Ok(None)` when no commit touched the file (e.g. untracked).
/// tree entry for `rel` differs from its first parent's; a merge that only
/// changed the file through its second parent is therefore not reported.
/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files).
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> { pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
let rel = rel.to_path_buf(); let rel = rel.to_path_buf();
Ok(last_commits(repo, std::slice::from_ref(&rel))? Ok(last_commits(repo, std::slice::from_ref(&rel))?
@@ -727,10 +718,8 @@ pub struct CommitDiff {
} }
/// The changes of the commit `id` (short or full) in the repository at /// The changes of the commit `id` (short or full) in the repository at
/// `workdir`, compared against its first parent (the empty tree for the root /// `workdir`, compared against its first parent (the empty tree for the
/// commit), like `git show`. Directory entries and submodules are skipped; /// root commit), like `git show`. Files are sorted by path.
/// their contents are reported as individual file changes. Files are sorted
/// by path.
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> { pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
commit_diff(&open_with_cache(workdir)?, id) commit_diff(&open_with_cache(workdir)?, id)
} }
+30 -56
View File
@@ -245,9 +245,8 @@ impl Backend {
/// Decrypt the NIP-49 encrypted credential stored in the keyring with /// Decrypt the NIP-49 encrypted credential stored in the keyring with
/// the given passphrase and resume the session. /// the given passphrase and resume the session.
/// ///
/// The scrypt decryption runs off the UI thread. The returned task /// The scrypt decryption runs off the UI thread. The task yields the
/// yields the public key on success, or the failure reason (e.g. wrong /// public key, or the failure reason (e.g. wrong passphrase).
/// passphrase), so callers can render inline errors.
pub fn restore_with_passphrase( pub fn restore_with_passphrase(
&mut self, &mut self,
password: &str, password: &str,
@@ -286,9 +285,8 @@ impl Backend {
/// passphrase (NIP-49) and persist it in the keyring, then publish the /// passphrase (NIP-49) and persist it in the keyring, then publish the
/// user's NIP-65 relay list, metadata and grasp list. /// user's NIP-65 relay list, metadata and grasp list.
/// ///
/// The heavy encryption runs off the UI thread. The returned task yields /// The encryption runs off the UI thread; the task yields the new
/// the new public key on success, or the failure reason, so callers can /// public key.
/// render progress and inline errors.
pub fn create_identity( pub fn create_identity(
&mut self, &mut self,
name: &str, name: &str,
@@ -386,11 +384,8 @@ impl Backend {
/// a push for a not-yet-existing repository while that authorization is /// a push for a not-yet-existing repository while that authorization is
/// pending (it expires after 30 minutes), like gitworkshop and ngit. /// pending (it expires after 30 minutes), like gitworkshop and ngit.
/// ///
/// The git work (init, commit, push) runs on background threads. The /// The git work runs on background threads; the task yields the
/// returned task yields the published announcement on success, so /// published announcement.
/// callers can open the new repository right away. The announcement's
/// `relays` tag carries the grasp servers, which are also added to the
/// relay pool so the published events reach them.
pub fn create_repository( pub fn create_repository(
&mut self, &mut self,
name: &str, name: &str,
@@ -435,8 +430,7 @@ impl Backend {
let servers = grasp_servers.clone(); let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// 1. Initialize the local clone (main branch + README + initial // Initialize the local clone (main branch + README + initial commit).
// commit) on a background thread.
let work = cx.background_spawn({ let work = cx.background_spawn({
let path = path.clone(); let path = path.clone();
let name = name.clone(); let name = name.clone();
@@ -467,16 +461,14 @@ impl Backend {
let commit_sha = let commit_sha =
Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?; Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?;
// 2. Ensure the grasp servers are in the relay pool; the nostr // The nostr client queues events until each relay is connected.
// client queues events until each relay is connected.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect(); let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx); this.add_relays(urls, cx);
})?; })?;
// 3. Publish the announcement, then the state event, to the // The state event is the push authorization ("purgatory"), so
// grasp relays. The state event is the push authorization // it must be accepted before the push below.
// ("purgatory"), so it must be accepted before step 4.
let announcement = GitRepositoryAnnouncement { let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(), id: repo_id.clone(),
name: Some(name.clone()), name: Some(name.clone()),
@@ -522,9 +514,8 @@ impl Backend {
} }
}; };
// 4. Push the initial commit to every grasp server. A server // Push to every grasp server; creation only fails when no
// that fails to accept the push is logged, but the creation // server accepted it.
// only fails when no server accepted it.
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
let owner = owner.clone(); let owner = owner.clone();
@@ -555,14 +546,8 @@ impl Backend {
/// state to the grasp relays, then push every branch and tag to each /// state to the grasp relays, then push every branch and tag to each
/// grasp server. Also points `origin` at the first grasp server. /// grasp server. Also points `origin` at the first grasp server.
/// ///
/// The events must reach the grasp servers *before* the push, like /// Same ordering constraint as [`Self::create_repository`]: the state
/// [`Self::create_repository`]: GRASP servers hold the signed state /// event ("purgatory") must be accepted before the push.
/// event in "purgatory" and only accept a push while that
/// authorization is pending.
///
/// The git work (ref listing, push) runs on background threads. The
/// returned task yields the published announcement on success, so
/// callers can switch the repository into its NIP-34 mode.
pub fn publish_local_repo( pub fn publish_local_repo(
&mut self, &mut self,
path: PathBuf, path: PathBuf,
@@ -586,9 +571,8 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a repository"))); return Task::ready(Err(anyhow!("Sign in to publish a repository")));
}; };
// The repository identifier is derived from the name, like // The repository identifier is derived from the name as in
// [`Self::create_repository`]: spaces become hyphens, other // [`Self::create_repository`].
// non-alphanumeric characters (except `/`) become hyphens.
let repo_id = identifier_from_name(&name); let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 { if repo_id.is_empty() || repo_id.len() > 100 {
@@ -607,8 +591,6 @@ impl Backend {
let servers = grasp_servers.clone(); let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// 1. Read the local repository's refs (branches, tags, HEAD)
// and its root commit on a background thread.
let work = cx.background_spawn({ let work = cx.background_spawn({
let path = path.clone(); let path = path.clone();
async move { async move {
@@ -619,16 +601,14 @@ impl Backend {
}); });
let (state, euc) = work.await?; let (state, euc) = work.await?;
// 2. Ensure the grasp servers are in the relay pool; the nostr // The nostr client queues events until each relay is connected.
// client queues events until each relay is connected.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect(); let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx); this.add_relays(urls, cx);
})?; })?;
// 3. Publish the announcement, then the state event, to the // The state event is the push authorization ("purgatory"), so
// grasp relays. The state event is the push authorization // it must be accepted before the push below.
// ("purgatory"), so it must be accepted before step 4.
let announcement = GitRepositoryAnnouncement { let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(), id: repo_id.clone(),
name: Some(name.clone()), name: Some(name.clone()),
@@ -672,10 +652,9 @@ impl Backend {
} }
}; };
// 4. Push every branch and tag to each grasp server. A server // Push every branch and tag to each grasp server; the init
// that fails to accept the push is logged, but the init only // only fails when no server accepted it. An empty repository
// fails when no server accepted it. An empty repository // has nothing to push.
// (no refs yet) has nothing to push.
if !refs.is_empty() { if !refs.is_empty() {
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
@@ -697,8 +676,8 @@ impl Backend {
} }
} }
// 5. Point `origin` at the first grasp server so later pushes // Point `origin` at the first grasp server so later pushes
// have a target, like the create flow. // have a target.
if let Some(base) = servers.first().and_then(grasp_base_url) { if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git"); let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone(); let path = path.clone();
@@ -732,15 +711,13 @@ impl Backend {
let relays = announcement.relays.clone(); let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// 1. Read the current refs of the local clone.
let work = cx.background_spawn({ let work = cx.background_spawn({
let path = path.clone(); let path = path.clone();
async move { signed_git::worktree_ref_state(&path) } async move { signed_git::worktree_ref_state(&path) }
}); });
let state = work.await?; let state = work.await?;
// 2. Publish a fresh state event; grasp servers authorize a // Grasp servers authorize a push by the state they have seen.
// push by the state they have seen.
let refs = state.refs.clone(); let refs = state.refs.clone();
let head = state.head.clone(); let head = state.head.clone();
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -749,7 +726,6 @@ impl Backend {
})? })?
.await?; .await?;
// 3. Push every branch and tag to the announced grasp servers.
if !refs.is_empty() { if !refs.is_empty() {
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
@@ -1111,9 +1087,8 @@ impl Backend {
/// `filters`, plus a negentropy sync so issues, patches and PRs stored /// `filters`, plus a negentropy sync so issues, patches and PRs stored
/// only on those relays are not missed. /// only on those relays are not missed.
/// ///
/// Best-effort: failures are logged, not surfaced, because the bootstrap /// Best-effort: failures are logged, not surfaced. The relays stay in
/// relays already cover the repository. The relays stay in the pool, so /// the pool, so later publishes for this repository also reach them.
/// events the user publishes for this repository also reach them.
pub fn connect_repo_relays( pub fn connect_repo_relays(
&mut self, &mut self,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
@@ -1221,10 +1196,9 @@ impl Backend {
/// Sign, broadcast and locally store an event. Emits /// Sign, broadcast and locally store an event. Emits
/// [`BackendEvent::Published`] on success so stores can refresh. /// [`BackendEvent::Published`] on success so stores can refresh.
/// ///
/// The returned task yields the outcome of this specific action, so /// The task yields the outcome of this specific action (for inline
/// callers can show inline progress/errors instead of relying on /// progress/errors) and is owned by the caller; dropping it cancels
/// the global [`BackendEvent::Error`]. The task is owned by the caller; /// the publish.
/// dropping it cancels the publish.
pub fn send( pub fn send(
&mut self, &mut self,
builder: EventBuilder, builder: EventBuilder,
+4 -24
View File
@@ -34,7 +34,6 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.install_default() .install_default()
.ok(); .ok();
// Initialize the nostr client and universal signer.
let (client, signer) = cx.foreground_executor().block_on(async move { let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf(); let path = db_path.as_ref().to_path_buf();
new_backend(path) new_backend(path)
@@ -42,24 +41,18 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.expect("failed to initialize nostr backend") .expect("failed to initialize nostr backend")
}); });
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before // Seed the explore list from the local database; relay syncs continue
// the first window opens, relay syncs continue in the background, // in the background.
// so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms, // The clone cache is native-only; wasm registers an empty store so
// the wasm build registers an empty store so `GitStore::global` still works. // `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
// Scan the default directories (Desktop, Documents) for local git
// repositories; the sidebar lists them next to the user's NIP-34 repos.
LocalReposStore::set_global( LocalReposStore::set_global(
cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)), cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
cx, cx,
@@ -71,26 +64,13 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
/// Initialize the backend with an in-memory database on wasm. /// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> { pub fn init(cx: &mut App) -> Entity<Backend> {
// Initialize the nostr client and universal signer.
let (client, signer) = new_backend().expect("failed to initialize nostr backend"); let (client, signer) = new_backend().expect("failed to initialize nostr backend");
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before
// the first window opens, relay syncs continue in the background,
// so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms,
// the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
// No filesystem scan on wasm: there are no local git repositories.
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
entity entity
+13 -19
View File
@@ -586,15 +586,12 @@ impl RepoStore {
/// (kind 1617) carrying the `git format-patch` output, which the PR /// (kind 1617) carrying the `git format-patch` output, which the PR
/// references via an `e` tag (NIP-34). /// references via an `e` tag (NIP-34).
/// ///
/// The patch is published first and the PR is sent once the patch /// The patch is published first so the PR can reference its id. The
/// event's id is known, so the two always arrive together. The proposed /// proposed commit is parsed from the patch's `From <commit>` header;
/// commit is parsed from the patch's `From <commit>` header; publishing /// without one publishing is refused, because the PR's `c` tag must
/// without one is refused, because the PR's `c` tag (and the patch's /// carry a real commit id for other NIP-34 clients to verify and apply
/// `commit`/`r` tags) must carry a real commit id for other NIP-34 /// the proposal. The `clone` tag carries the announced mirror URLs; the
/// clients to verify and apply the proposal. The PR's `clone` tag /// linked patch is the source of truth until the commit is pushed there.
/// carries the repository's announced mirror URLs (the commit may not be
/// pushed there yet; the linked patch is the source of truth until a
/// push backend exists).
pub fn open_pull_request( pub fn open_pull_request(
&mut self, &mut self,
subject: Option<String>, subject: Option<String>,
@@ -783,10 +780,9 @@ impl RepoStore {
/// the merged status. /// the merged status.
/// ///
/// Only the repository author may merge. The clone is created on demand /// Only the repository author may merge. The clone is created on demand
/// from the announcement's clone URLs when the repository hasn't been /// from the announcement's clone URLs when needed. Patch application
/// mirrored locally yet. Patch application runs on a background thread /// (`git am`) runs on a background thread; failures (e.g. a patch that
/// (`git am`); failures (e.g. a patch that no longer applies) surface in /// no longer applies) surface in [`Self::last_error`].
/// [`Self::last_error`] and no status is sent.
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) { pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
@@ -876,12 +872,10 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
hex.split_whitespace().next().filter(|hex| hex.len() == 40) hex.split_whitespace().next().filter(|hex| hex.len() == 40)
} }
/// Build a NIP-22 kind-1111 comment using the SDK's [`CommentBuilder`]: /// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
/// uppercase `E`/`K`/`P` tags scope the thread root, lowercase `e`/`k`/`p` /// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
/// tags the direct parent (`parent`, or the root itself for a top-level /// top-level comment). An `a` tag with the repository coordinate (not part
/// comment). An `a` tag with the repository coordinate is added so Signed's /// of NIP-22) is added so Signed's own activity subscriptions also match.
/// own activity subscriptions also match the comment (it is not part of
/// NIP-22).
fn comment_builder( fn comment_builder(
root: &Event, root: &Event,
parent: Option<&Event>, parent: Option<&Event>,
+1 -2
View File
@@ -22,8 +22,7 @@ impl Global for GlobalRepoListStore {}
/// Counts of NIP-34 activity events per repository, used to rank the /// Counts of NIP-34 activity events per repository, used to rank the
/// explore list by popularity. Each patch event is a pushed commit (or a /// explore list by popularity. Each patch event is a pushed commit (or a
/// small commit series), which is the closest cross-repository proxy for /// small series), the closest proxy for commit count in the event data.
/// commit count available from event data alone.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts { pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository. /// Root `30611` issue events addressed to the repository.
@@ -39,12 +39,9 @@ pub(super) enum FileContent {
/// A markdown document loaded into a persistent [`TextViewState`]. /// A markdown document loaded into a persistent [`TextViewState`].
/// ///
/// The state is owned by the view rather than created per render (as the /// The state is owned by the view rather than created per render: GPUI
/// stateless `text::markdown` helper does), so it survives branch switches /// drops keyed element state after one absent frame, which would re-parse
/// in the content pane. GPUI's keyed element state is dropped as soon as the /// the whole document on every pane switch (README / file / spinner).
/// element is absent for a single frame, which would otherwise re-parse the
/// whole document on the main thread every time the pane switches between
/// the README, a file preview, and the loading spinner.
pub(super) struct MarkdownView { pub(super) struct MarkdownView {
/// Source path; `None` means the repository README. /// Source path; `None` means the repository README.
pub(super) path: Option<SharedString>, pub(super) path: Option<SharedString>,
@@ -53,11 +50,7 @@ pub(super) struct MarkdownView {
/// A code file loaded into a persistent [`InputState`], rendered as a /// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers /// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search. /// and search. Persistent for the same reason as [`MarkdownView`].
///
/// Same persistence rationale as [`MarkdownView`]: the state lives as long
/// as this view, so re-viewing the same file does not re-parse it, and
/// parsing happens on a background task inside the editor.
pub(super) struct CodeView { pub(super) struct CodeView {
/// Source path, relative to the worktree root. /// Source path, relative to the worktree root.
pub(super) path: SharedString, pub(super) path: SharedString,
@@ -230,9 +223,7 @@ impl RepoDetailView {
/// Load `text` into the persistent markdown TextView state. /// Load `text` into the persistent markdown TextView state.
/// ///
/// The state is created empty and fed via `push_str`, which parses on a /// The state is created empty and fed via `push_str`, which parses on a
/// background task: switching files never blocks the main thread, and /// background task, so switching files never blocks the main thread.
/// the state lives as long as this view, so re-viewing the same document
/// does not re-parse it.
pub(super) fn set_markdown( pub(super) fn set_markdown(
&mut self, &mut self,
path: Option<SharedString>, path: Option<SharedString>,
@@ -269,10 +260,8 @@ impl RepoDetailView {
/// Load `text` into the persistent code editor state for `path`. /// Load `text` into the persistent code editor state for `path`.
/// ///
/// The state is created in code editor mode so the Input renders it as /// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor. Like [`set_markdown`], the /// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// state lives as long as this view, so re-viewing the same file does /// on a background task like [`set_markdown`]'s.
/// not re-parse it; the tree-sitter parse runs on a background task
/// inside the editor instead of blocking the main thread.
pub(super) fn set_code( pub(super) fn set_code(
&mut self, &mut self,
path: SharedString, path: SharedString,
@@ -276,11 +276,8 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
/// A split dropdown button built on `gpui_base::Popover`: an action element /// A split dropdown button built on `gpui_base::Popover`: an action element
/// with a separate caret trigger that opens a [`PopupMenu`]. /// with a separate caret trigger that opens a [`PopupMenu`].
/// ///
/// The action and the caret are ordinary elements supplied by the caller, so /// The action and the caret are caller-supplied elements, so the look stays
/// the look — icons, borders, hover states, sizes — stays fully in the /// in the application; this component only owns the popover wiring.
/// application. The component only owns the popover wiring: opening on caret
/// click, Escape/outside dismissal, focus movement into the menu, and the
/// menu entity's lifecycle.
#[derive(IntoElement)] #[derive(IntoElement)]
pub(super) struct BaseDropdownButton { pub(super) struct BaseDropdownButton {
id: ElementId, id: ElementId,
@@ -493,11 +490,10 @@ impl ShareTargets {
} }
} }
/// One row of the share menu: a small title on top of the compact label, /// One row of the share menu: a small title above the compact label, with
/// with a copy button that flips to a check while the value is on the /// a copy button that flips to a check while the value is on the clipboard.
/// clipboard. Clicking the row text copies and dismisses the menu; the copy /// Clicking the row copies and dismisses the menu; the copy button stops
/// button stops propagation, so the menu stays open for further copies. /// propagation so the menu stays open. Both copy `copy`, never the label.
/// Both copy `copy`, never the truncated label.
pub(super) fn share_menu_row( pub(super) fn share_menu_row(
id: &'static str, id: &'static str,
title: &'static str, title: &'static str,
@@ -26,10 +26,8 @@ use super::helpers::{placeholder, status_badge};
use super::issue_detail::IssueDetailView; use super::issue_detail::IssueDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::image_cache::{MAX_IMAGES, image_cache};
/// Height of one issue row in the virtual list: 8px vertical padding /// Height of one issue row in the virtual list: `py_2` padding, a 32px
/// (`py_2`) on top and bottom, a 32px title line (`h_8`) and a 24px meta /// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
/// line (`h_6`), plus the 1px bottom border; the row totals 73px. The
/// status chip (`size_7`, 28px) is shorter than the content.
const ISSUE_ROW_HEIGHT: f32 = 73.; const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons. /// Status filter of the issues list, chosen via the header's filter buttons.
@@ -39,8 +37,8 @@ enum IssueFilter {
All, All,
/// Issues whose resolved status is [`RepoStatus::Open`]. /// Issues whose resolved status is [`RepoStatus::Open`].
Open, Open,
/// Issues whose resolved status is /// Issues whose resolved status is [`RepoStatus::Closed`] or
/// [`RepoStatus::Closed`] or [`RepoStatus::Applied`] (both are "done" states). /// [`RepoStatus::Applied`] (both are "done" states).
Closed, Closed,
} }
+6 -16
View File
@@ -308,9 +308,7 @@ impl RepoDetailView {
/// (not yet published) repository is opened straight from disk. An /// (not yet published) repository is opened straight from disk. An
/// announced repository's local clone (if any) is loaded first without /// announced repository's local clone (if any) is loaded first without
/// touching the network, so an unreachable server can't block the /// touching the network, so an unreachable server can't block the
/// panel; a background fetch then refreshes the refs and commit list /// panel; a background fetch then refreshes the refs and commit list.
/// (a fetch never changes the checked-out files, so the tree and
/// previews are left alone).
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -505,9 +503,7 @@ impl RepoDetailView {
} }
/// Clone the repository into a folder chosen by the user (outside the /// Clone the repository into a folder chosen by the user (outside the
/// cache), then open the new clone in the system file manager. Like /// cache), then open the new clone in the system file manager.
/// ngit's clone, this resolves the announcement's `clone` URLs and
/// clones from the first working git server.
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.cloning { if self.cloning {
return; return;
@@ -722,11 +718,8 @@ impl RepoDetailView {
/// Walk history once for every queued path on a background task, and /// Walk history once for every queued path on a background task, and
/// cache the latest commit touching each of them in [`Self::commits`] /// cache the latest commit touching each of them in [`Self::commits`]
/// (for the file header in the content column). /// (for the file header in the content column). Batching shares one
/// /// walk across all paths queued while the previous walk was in flight.
/// Batching shares one walk (and its object decodes) across all paths
/// queued while the previous walk was in flight, instead of walking the
/// full history per file.
fn load_commits(&mut self, cx: &mut Context<Self>) { fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits { if self.pending_commits.is_empty() || self.loading_commits {
return; return;
@@ -1029,8 +1022,7 @@ impl RepoDetailView {
/// Trigger body for the branch/tag selectors: the kind icon, the /// Trigger body for the branch/tag selectors: the kind icon, the
/// selection (or placeholder) and the caret. `Combobox` replaces its /// selection (or placeholder) and the caret. `Combobox` replaces its
/// default trigger entirely, which is the only way to show an icon /// default trigger entirely, the only way to show an icon inside it.
/// inside the trigger label.
fn render_ref_trigger( fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>, ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName, icon: CustomIconName,
@@ -1923,9 +1915,7 @@ impl Render for RepoDetailView {
} }
/// Read the worktree state of `repo` (no network): entries, README, refs /// Read the worktree state of `repo` (no network): entries, README, refs
/// and HEAD commit. The tree is built off the main thread; the seeds are /// and HEAD commit.
/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state)
/// on the main thread.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> { fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?; let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries); let tree = build_tree_items(&entries);
@@ -146,9 +146,8 @@ impl PullRequestDetailView {
/// and commit list on a background task and populate the tree. /// and commit list on a background task and populate the tree.
/// ///
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch /// The changes come from the PR's patch set (NIP-34 `e`-linked patch
/// events) when present; otherwise they live in the git repository /// events) when present; otherwise from the git repository (`c`,
/// (`c`, `clone` and `merge-base` tags, per NIP-34), so the clone is /// `clone` and `merge-base` tags), diffing the `merge-base..tip` range.
/// fetched and the `merge-base..tip` range is diffed.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -26,11 +26,8 @@ use super::helpers::{placeholder, status_badge};
use super::pull_request_detail::PullRequestDetailView; use super::pull_request_detail::PullRequestDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::image_cache::{MAX_IMAGES, image_cache};
/// Height of one pull request row in the virtual list: same layout as an /// Height of one pull request row in the virtual list; same layout as an
/// issue row (8px vertical padding (`py_2`) on top and bottom, a 32px title /// issue row.
/// line (`h_8`) and a 24px meta line (`h_6`), plus the 1px bottom border),
/// so the row totals 73px. The status badge (`size_7`, 28px) is shorter
/// than the content.
const PR_ROW_HEIGHT: f32 = 73.; const PR_ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter /// Status filter of the pull request list, chosen via the header's filter
@@ -79,9 +76,8 @@ pub struct PullRequestsView {
/// pull request count); rebuilt on change. /// pull request count); rebuilt on change.
pr_len: usize, pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`] /// Indices into the store's `pull_requests` matching [`Self::filter`]
/// (root PR events only; updates are revisions of the root and are not /// (root PR events only; updates are revisions of the root), rebuilt
/// listed separately), rebuilt every render; the virtual list renders /// every render; the virtual list renders this slice.
/// this slice.
visible_prs: Vec<usize>, visible_prs: Vec<usize>,
/// Virtual list state of the pull requests list. /// Virtual list state of the pull requests list.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
+2 -7
View File
@@ -97,9 +97,7 @@ pub struct RepoListView {
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count). /// Number of rows [`Self::item_sizes`] was built for (the filtered repo count).
repo_len: usize, repo_len: usize,
/// Indices into the store's `announcements` matching [`Self::filter`], /// Indices into the store's `announcements` matching [`Self::filter`],
/// in display order; rebuilt when the store changes, the filter is /// in display order; the virtual list renders this slice.
/// switched, or the search text changes. The virtual list renders this
/// slice.
visible: Vec<usize>, visible: Vec<usize>,
/// Search box filtering repositories by name. /// Search box filtering repositories by name.
search: Entity<InputState>, search: Entity<InputState>,
@@ -153,10 +151,7 @@ impl RepoListView {
} }
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current
/// store contents, [`Self::filter`] and the search query. Called when /// store contents, [`Self::filter`] and the search query.
/// the view is created, when the store changes, when the filter is
/// switched, and on every search keystroke, so the list is ready before
/// the next render.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) { fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter; let filter = self.filter;
let query = self.search.read(cx).value(); let query = self.search.read(cx).value();
+2 -3
View File
@@ -417,9 +417,8 @@ impl SidebarPanel {
) )
} }
/// Sign-in placeholder shown while logged out: the banner artwork fills the /// Sign-in placeholder shown while logged out: banner artwork behind a
/// panel behind a scrim that ends in a solid black band, keeping the CTA /// scrim so the CTA buttons stay readable in both themes.
/// buttons readable on a clean dark surface in both themes.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div { fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex() v_flex()
.size_full() .size_full()
+2 -14
View File
@@ -7,17 +7,13 @@ use gpui_component::{Theme, ThemeRegistry, theme};
use gpui_platform::application; use gpui_platform::application;
fn main() { fn main() {
// Initialize logging
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
application() application()
.with_assets(Assets) .with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
.run(move |cx| { .run(move |cx| {
// Initialize components
gpui_component::init(cx); gpui_component::init(cx);
// Initialize theme
theme::init(cx); theme::init(cx);
// Register the built-in "Signed" theme (light + dark variants) // Register the built-in "Signed" theme (light + dark variants)
@@ -49,10 +45,9 @@ fn main() {
tracing::warn!("Signed Dark theme is missing from the registry"); tracing::warn!("Signed Dark theme is missing from the registry");
} }
// Sync the theme with the system appearance
Theme::sync_system_appearance(None, cx); Theme::sync_system_appearance(None, cx);
// Initialize backend and stores (connects relays, restores session) // Connects relays and restores the session.
std::fs::create_dir_all(paths::nostr_dir()).ok(); std::fs::create_dir_all(paths::nostr_dir()).ok();
signed_state::init(paths::nostr_dir(), cx); signed_state::init(paths::nostr_dir(), cx);
@@ -60,15 +55,9 @@ fn main() {
std::fs::create_dir_all(paths::repos_dir()).ok(); std::fs::create_dir_all(paths::repos_dir()).ok();
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
// Set app identity
cx.set_app_identity("su.reya.signed", "Signed"); cx.set_app_identity("su.reya.signed", "Signed");
// Set up the window options
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx); let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
// The dock's tab bar acts as the window title bar: the app owns
// title-bar dragging (via `start_window_move` on the tab bar), so
// AppKit must not treat the top strip as a native drag region.
let opts = WindowOptions { let opts = WindowOptions {
window_background: WindowBackgroundAppearance::Opaque, window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client), window_decorations: Some(WindowDecorations::Client),
@@ -78,7 +67,7 @@ fn main() {
app_id: Some("Signed".to_owned()), app_id: Some("Signed".to_owned()),
titlebar: Some(TitlebarOptions { titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static("Signed")), title: Some(SharedString::new_static("Signed")),
// AppKit's traffic-light buttons are 14 pt tall; offset them so their vertical center matches the tab bar. // Center the 14pt traffic-light buttons on the tab bar.
traffic_light_position: Some(point( traffic_light_position: Some(point(
px(9.0), px(9.0),
px(TAB_BAR_HEIGHT / px(2.) - 14. / 2.), px(TAB_BAR_HEIGHT / px(2.) - 14. / 2.),
@@ -94,7 +83,6 @@ fn main() {
}) })
.detach(); .detach();
// Bring the app to the foreground
cx.activate(true); cx.activate(true);
}); });
} }
+2 -2
View File
@@ -2,8 +2,8 @@
## Local repository scan ## Local repository scan
- [ ] Allow the user to configure which directories are scanned for local git repositories (currently fixed to the Desktop and Documents folders). - [ ] Make the scanned directories configurable (currently fixed to Desktop and Documents).
## Create repository dialog ## Create repository dialog
- [ ] Persist the user's preferred local repository folder (the one picked in the create-repository dialog, defaulting to Desktop) and use it as the default next time the dialog opens. - [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop).