Compare commits
43
Commits
d68c098818
...
redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2124b00590 | ||
|
|
a249174026 | ||
|
|
4f81501668 | ||
|
|
d6850d60ea | ||
|
|
4b4b8b5cf2 | ||
|
|
a2e7907193 | ||
|
|
946128433b | ||
|
|
30ff255372 | ||
|
|
cd61a09320 | ||
|
|
e2edb9dd12 | ||
|
|
5e061cf860 | ||
|
|
4c252dc589 | ||
|
|
e72e04c471 | ||
|
|
b78418d815 | ||
|
|
7878feb236 | ||
|
|
4d8aa97e24 | ||
|
|
31256a4870 | ||
|
|
5586f67f0e | ||
|
|
5887a14c50 | ||
|
|
a706fadafa | ||
|
|
8900d1f3ee | ||
|
|
237b6a6996 | ||
|
|
62f355d70e | ||
|
|
f2a0ed002d | ||
|
|
cd72b25626 | ||
|
|
a4df8e5731 | ||
|
|
d4ba71a362 | ||
|
|
6ec4b7e813 | ||
|
|
c76e909f7f | ||
|
|
6c4cc9f2ca | ||
|
|
a87030e929 | ||
|
|
272ddf2674 | ||
|
|
fa50e9f153 | ||
|
|
da22d26019 | ||
|
|
0ff6740a9e | ||
|
|
e2bce4e074 | ||
|
|
a85b5ee87b | ||
|
|
e61caf8482 | ||
|
|
a02e44c0f8 | ||
|
|
b526e13f45 | ||
|
|
5e6156066a | ||
|
|
a74c166391 | ||
|
|
f6b8a5e133 |
@@ -159,7 +159,7 @@ jobs:
|
||||
id: create_release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
server_url: "https://git.reya.su/"
|
||||
server_url: "https://git.reya.info/"
|
||||
repository: "reya/signed"
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
draft: true
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ dist/
|
||||
|
||||
# Snap and Flatpak local build output
|
||||
/snap
|
||||
/su.reya.signed.json
|
||||
/info.reya.signed.json
|
||||
/linux-artifacts
|
||||
|
||||
# Vendored dependencies + cargo config generated by script/prepare-flathub
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
# Rust coding guidelines
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Avoid creative additions unless explicitly requested
|
||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||
Example:
|
||||
```rust
|
||||
executor.spawn({
|
||||
let task_ran = task_ran.clone();
|
||||
async move {
|
||||
*task_ran.borrow_mut() = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
# Timers in tests
|
||||
|
||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||
|
||||
# GPUI
|
||||
|
||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||
|
||||
## Context
|
||||
|
||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||
|
||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||
|
||||
## `Window`
|
||||
|
||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||
|
||||
## Entities
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||
|
||||
* `thing.entity_id()` returns `EntityId`
|
||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||
* `thing.read(cx: &App)` returns `&T`.
|
||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||
|
||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||
|
||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||
|
||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All use of entities and UI rendering occurs on a single foreground thread.
|
||||
|
||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||
|
||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||
|
||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||
|
||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||
|
||||
* Awaiting the task in some other async context.
|
||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `Fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `git_ui: Add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
|
||||
# Rules Hygiene
|
||||
|
||||
These `.rules` files are read by every agent session. Keep them high-signal.
|
||||
|
||||
## After any agentic session
|
||||
If you discover a non-obvious pattern that would help future sessions, include a **"Suggested .rules additions"** heading in your PR description with the proposed text. Do **not** edit `.rules` inline during normal feature/fix work. Reviewers decide what gets merged.
|
||||
|
||||
## High bar for new rules
|
||||
Editing or clarifying existing rules is always welcome. New rules must meet **all three** criteria:
|
||||
1. **Non-obvious** — someone familiar with the codebase would still get it wrong without the rule.
|
||||
2. **Repeatedly encountered** — it came up more than once (multiple hits in one session counts).
|
||||
3. **Specific enough to act on** — a concrete instruction, not a vague principle.
|
||||
|
||||
Rules that apply to a single crate belong in that crate's own `.rules` file, not the repo root.
|
||||
|
||||
## What NOT to put in `.rules`
|
||||
Avoid architectural descriptions of a crate (module layout, data flow, key types). These go stale fast and the agent can gather them by reading the code. Rules should be **traps to avoid**, not **maps to follow**.
|
||||
|
||||
## No drive-by additions
|
||||
Rules emerge from validated patterns, not one-off observations. The workflow is:
|
||||
1. Agent notes a pattern during a session.
|
||||
2. Team validates the pattern in code review.
|
||||
3. A dedicated commit adds the rule with context on *why* it exists.
|
||||
@@ -1 +1,162 @@
|
||||
.rules
|
||||
# Rust coding guidelines
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
|
||||
* Do not wrap clients or SDK dependencies in project structs or pass-through helper functions, call their methods directly where needed. Extract a helper only when it holds real logic that is used in more than one place.
|
||||
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Avoid creative additions unless explicitly requested
|
||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||
Example:
|
||||
```rust
|
||||
executor.spawn({
|
||||
let task_ran = task_ran.clone();
|
||||
async move {
|
||||
*task_ran.borrow_mut() = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
# Timers in tests
|
||||
|
||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||
|
||||
# GPUI
|
||||
|
||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||
|
||||
## Context
|
||||
|
||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||
|
||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||
|
||||
## `Window`
|
||||
|
||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||
|
||||
## Entities
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||
|
||||
* `thing.entity_id()` returns `EntityId`
|
||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||
* `thing.read(cx: &App)` returns `&T`.
|
||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||
|
||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||
|
||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||
|
||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All use of entities and UI rendering occurs on a single foreground thread.
|
||||
|
||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||
|
||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||
|
||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||
|
||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||
|
||||
* Awaiting the task in some other async context.
|
||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
GPUI also tracks the entities a window reads while rendering. When an entity notifies, every view that read that entity during the previous frame is rerendered automatically.
|
||||
|
||||
* A view that reads an entity in render (e.g. `state.read(cx).profile(...)`) does not need `cx.observe(&entity, ...)` to pick up its changes - the read itself is the dependency. Reserve `cx.observe` and `cx.subscribe` for when the view must run logic in response to a change (rebuild derived state, re-query, dedupe), not to trigger a re-render.
|
||||
* Notify the narrowest entity holding the change. Notifying a global store rerenders every view that ever read it; notifying a per-panel entity rerenders only the panels reading it.
|
||||
* This works with cached panels (`panel.cached(...)`): a cached subtree repaints when the view it belongs to is invalidated.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## v0.2.0 - 2026/10/21
|
||||
|
||||
### Added
|
||||
|
||||
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences.
|
||||
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
|
||||
- Add a dropdown menu on the signed-in user button in the sidebar
|
||||
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
|
||||
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
|
||||
- Allow choosing the default branch when publishing a repository
|
||||
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
|
||||
- Add a step-by-step push checklist while publishing and syncing a repository
|
||||
- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
|
||||
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
|
||||
- Surface backend errors as error notifications in the workspace instead of silently dropping them
|
||||
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
||||
- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils`
|
||||
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
|
||||
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
|
||||
- Refresh the checkouts store only when the checkouts settings change
|
||||
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
|
||||
- Publish nostr events directly through the client with relay acceptance required, removing the grasp push helper and its per-server retry loop so failures surface immediately
|
||||
- Poll the repository sync drift check every 180 seconds instead of 60
|
||||
- Track sync status per view instead of globally
|
||||
- Merge the `PixelAvatar` into the `Avatar` component. Seed user pixel avatars from the public key instead of the display name, so avatars stay stable when a user renames
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the repository view refreshing its mirror from the network while a push is in progress, so the refresh no longer races the push
|
||||
- Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone
|
||||
- Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned
|
||||
- Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed
|
||||
- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks
|
||||
- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
|
||||
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
|
||||
|
||||
## v0.2.0-alpha - 2026/09/27
|
||||
|
||||
### Added
|
||||
|
||||
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
|
||||
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
|
||||
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
||||
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
||||
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
||||
- Connect to bootstrap relays on demand instead of at startup
|
||||
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
|
||||
- Prefetch the 500 most recent profiles at startup instead of 200
|
||||
|
||||
### Fixed
|
||||
|
||||
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
|
||||
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
|
||||
- Fix background tasks outliving their view, so a closed repository panel or pull request view stops fetching and publishing on its own
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
|
||||
|
||||
## v0.1.0-alpha - 2026/09/14
|
||||
|
||||
Initial alpha release.
|
||||
|
||||
### Added
|
||||
|
||||
- Create and unlock a passphrase-protected Nostr identity
|
||||
- Browse and search NIP-34 repositories with All, Popular and Recent filters
|
||||
- Scan local Git repositories and detect their NIP-34 and GRASP bindings
|
||||
- Create, publish, clone and open repositories
|
||||
- Repository view with files, commit history, branches and tags
|
||||
- Colour-coded commit diffs
|
||||
- Issues and pull requests with discussions
|
||||
- Send patches and push branches
|
||||
- Inbox for repository activity
|
||||
- Settings for appearance, theme, GRASP servers and repository scan paths
|
||||
- Release and packaging infrastructure
|
||||
Generated
+1424
-1207
File diff suppressed because it is too large
Load Diff
+12
-16
@@ -4,34 +4,34 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0-alpha"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
|
||||
# GPUI Kit
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
# GPUI Kit; exact pins keep the graph on the gpui-pre 0.3.8 closure that
|
||||
# native-theme-gpui 0.6.1 is verified against (0.7.3 and 0.8 pin 0.3.9).
|
||||
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "=0.7.1"
|
||||
|
||||
native-theme = { version = "0.6.1", features = ["watch"] }
|
||||
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
||||
gix = { version = "0.88", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
||||
|
||||
smol = "2"
|
||||
futures = "0.3"
|
||||
@@ -48,10 +48,6 @@ tracing = "0.1"
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -77,9 +77,9 @@
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#CECECE",
|
||||
"switch.thumb.background": "#FFFFFF",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#EBFFC1",
|
||||
"tab.active.foreground": "#3F6212",
|
||||
"tab.background": "#E8E8E8",
|
||||
"tab.active.background": "#F5F5F5",
|
||||
"tab.active.foreground": "#18181B",
|
||||
"tab.foreground": "#646464",
|
||||
"tab_bar.background": "#F0F0F0",
|
||||
"tab_bar.segmented.background": "#F0F0F0",
|
||||
@@ -286,9 +286,9 @@
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#484848",
|
||||
"switch.thumb.background": "#0A0A0A",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#19200A",
|
||||
"tab.active.foreground": "#C6FF4D",
|
||||
"tab.background": "#2E2E2E",
|
||||
"tab.active.background": "#2A2A2A",
|
||||
"tab.active.foreground": "#FAFAFA",
|
||||
"tab.foreground": "#B4B4B4",
|
||||
"tab_bar.background": "#191919",
|
||||
"tab_bar.segmented.background": "#191919",
|
||||
|
||||
@@ -39,12 +39,7 @@ impl Assets {
|
||||
.filter_map(|path| {
|
||||
let data = Self::get(path.as_ref())?;
|
||||
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
||||
let content = match data.data {
|
||||
std::borrow::Cow::Borrowed(bytes) => {
|
||||
std::str::from_utf8(bytes).ok()?.to_owned()
|
||||
}
|
||||
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
|
||||
};
|
||||
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
|
||||
Some((name.to_owned(), content))
|
||||
})
|
||||
.collect()
|
||||
@@ -111,61 +106,3 @@ impl IconNamed for CustomIconName {
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signed_theme_set() -> gpui_component::ThemeSet {
|
||||
let themes = Assets.themes();
|
||||
assert_eq!(themes.len(), 1, "expected exactly one embedded theme file");
|
||||
let (name, content) = &themes[0];
|
||||
assert_eq!(name, "signed.json");
|
||||
serde_json::from_str(content).expect("theme file must be a valid ThemeSet")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_theme_set_parses() {
|
||||
let set = signed_theme_set();
|
||||
let names: Vec<&str> = set.themes.iter().map(|t| t.name.as_ref()).collect();
|
||||
assert_eq!(names, vec!["Signed Light", "Signed Dark"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_theme_palette_applies() {
|
||||
let set = signed_theme_set();
|
||||
let parse = |hex: &str| gpui_component::try_parse_color(hex).unwrap();
|
||||
for config in &set.themes {
|
||||
let mut theme = gpui_component::Theme::default();
|
||||
theme.apply_config(&std::rc::Rc::new(config.clone()));
|
||||
|
||||
assert_eq!(theme.mode, config.mode);
|
||||
// The resolved colors must match the brand palette.
|
||||
assert_eq!(theme.primary, parse("#C6FF4D")); // nostr-lime
|
||||
assert_eq!(theme.success, parse("#2FBF71")); // merge
|
||||
assert_eq!(theme.primary_active, parse("#65A30D")); // lime-600
|
||||
|
||||
if config.mode.is_dark() {
|
||||
// Dark theme chrome is neutral, mirroring the light theme.
|
||||
assert_eq!(theme.background, parse("#0A0A0A")); // neutral-950
|
||||
assert_eq!(theme.border, parse("#27272A")); // neutral-800
|
||||
assert_eq!(theme.green, parse("#22C55E")); // green-500
|
||||
} else {
|
||||
// Light theme chrome is neutral, lime is a brand accent only.
|
||||
assert_eq!(theme.background, parse("#FFFFFF"));
|
||||
assert_eq!(theme.foreground, parse("#18181B"));
|
||||
assert_eq!(theme.border, parse("#E4E4E7"));
|
||||
assert_eq!(theme.green, parse("#16A34A"));
|
||||
}
|
||||
// Active tab, a paler lime on light and a dim moss on dark.
|
||||
// Each is paired with readable contrasting text.
|
||||
if config.mode.is_dark() {
|
||||
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
|
||||
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
|
||||
} else {
|
||||
assert_eq!(theme.tab_active, parse("#EBFFC1")); // pale nostr-lime
|
||||
assert_eq!(theme.tab_active_foreground, parse("#3F6212")); // deep-lime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
settings = { path = "../settings" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -11,22 +11,19 @@ use gpui::{
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||
PanelView, TabGroupRenderer, TilesRenderer,
|
||||
PanelView, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::resize_handle;
|
||||
use gpui_component::scroll::ScrollbarMode;
|
||||
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
|
||||
use gpui_base::{HandleEdge, resize_handle};
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::invalid_panel::InvalidPanel;
|
||||
use crate::panel_handle;
|
||||
use crate::tab_panel::SignedTabGroupSkin;
|
||||
use crate::tiles::SignedTilesSkin;
|
||||
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
||||
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
tiles_scrollbar_mode: Cell<Option<ScrollbarMode>>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
@@ -40,18 +37,9 @@ impl SkinShared {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
||||
self.tiles_scrollbar_mode.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&self.resizing_dock
|
||||
}
|
||||
|
||||
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
|
||||
pub(crate) fn notify(&self, cx: &mut App) {
|
||||
_ = self.area.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
|
||||
/// The Signed appearance for a [`DockArea`].
|
||||
@@ -73,7 +61,6 @@ impl SignedDockSkin {
|
||||
shared: Rc::new(SkinShared {
|
||||
area: cx.weak_entity(),
|
||||
toggle_button_visible: Cell::new(true),
|
||||
tiles_scrollbar_mode: Cell::new(None),
|
||||
resizing_dock: Cell::new(None),
|
||||
}),
|
||||
})
|
||||
@@ -82,26 +69,6 @@ impl SignedDockSkin {
|
||||
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
|
||||
&self.shared
|
||||
}
|
||||
|
||||
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
|
||||
pub fn is_toggle_button_visible(&self) -> bool {
|
||||
self.shared.is_toggle_button_visible()
|
||||
}
|
||||
|
||||
pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) {
|
||||
self.shared.toggle_button_visible.set(visible);
|
||||
self.shared.notify(cx);
|
||||
}
|
||||
|
||||
/// When a tiles canvas shows its scrollbar. `None` follows the theme.
|
||||
pub fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
||||
self.shared.tiles_scrollbar_mode()
|
||||
}
|
||||
|
||||
pub fn set_tiles_scrollbar_mode(&self, mode: Option<ScrollbarMode>, cx: &mut App) {
|
||||
self.shared.tiles_scrollbar_mode.set(mode);
|
||||
self.shared.notify(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload a dock's resize handle drags.
|
||||
@@ -154,30 +121,10 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let placement = dock.placement();
|
||||
let open = dock.is_open();
|
||||
|
||||
// A closed left or right dock takes no space.
|
||||
// A closed bottom dock keeps a strip so its tab bar stays clickable.
|
||||
if !open && !placement.is_bottom() {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.map(|this| match placement {
|
||||
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
|
||||
DockPlacement::Bottom => this.w_full().h(dock.size()),
|
||||
// Base never builds a dock for the centre.
|
||||
DockPlacement::Center => this,
|
||||
})
|
||||
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
|
||||
.when(!open && placement.is_bottom(), |this| {
|
||||
this.h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
.child(content)
|
||||
.child(self.render_resize_handle(dock, window, cx))
|
||||
.child(DockResizeTracker {
|
||||
@@ -204,10 +151,6 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||
}
|
||||
|
||||
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
||||
Rc::new(SignedTilesSkin::new(self.shared().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SignedDockSkin {
|
||||
@@ -221,7 +164,12 @@ impl SignedDockSkin {
|
||||
let shared = self.shared().clone();
|
||||
|
||||
resize_handle("resize-handle", placement.axis())
|
||||
.when(placement.is_left(), |this| this.placement(Side::Left))
|
||||
.when(placement.is_left(), |this| {
|
||||
this.inside(HandleEdge::Trailing)
|
||||
})
|
||||
.when(placement.is_right() || placement.is_bottom(), |this| {
|
||||
this.inside(HandleEdge::Leading)
|
||||
})
|
||||
.on_drag(ResizePanel, move |info, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
shared.resizing_dock().set(Some(placement));
|
||||
|
||||
@@ -6,7 +6,6 @@ use gpui_base::dock::PanelView;
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
mod tab_panel;
|
||||
mod tiles;
|
||||
mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
@@ -26,19 +25,6 @@ pub fn add_center_panel(
|
||||
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
||||
}
|
||||
|
||||
/// Add an already-wrapped panel handle to the bottom dock of `area`.
|
||||
///
|
||||
/// Used for sub-views that hang under the center, such as the inbox's Unread
|
||||
/// and Archived lists.
|
||||
pub fn add_bottom_panel(
|
||||
area: &mut DockArea,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
area.add_panel_view(panel, DockPlacement::Bottom, 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.);
|
||||
|
||||
|
||||
+305
-177
@@ -5,9 +5,9 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
|
||||
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
|
||||
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
||||
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
||||
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled as _, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
@@ -18,17 +18,24 @@ 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,
|
||||
};
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
use settings::{SettingsStore, TabBarSettings};
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
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.));
|
||||
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
|
||||
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
|
||||
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
|
||||
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
|
||||
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
|
||||
|
||||
/// Whether a group's prev/next buttons should be drawn.
|
||||
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
|
||||
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
|
||||
}
|
||||
|
||||
/// A panel's title, or its registered name when the panel has no handle.
|
||||
pub(crate) fn panel_title(
|
||||
@@ -43,24 +50,20 @@ pub(crate) fn panel_title(
|
||||
}
|
||||
|
||||
/// The preview that follows the cursor while a panel is dragged.
|
||||
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
||||
struct DragPanelPreview {
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
}
|
||||
|
||||
impl Render for DragPanelPreview {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
h_flex()
|
||||
.id("drag-panel")
|
||||
.cursor_grab()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.w_24()
|
||||
.overflow_hidden()
|
||||
.p_1()
|
||||
.whitespace_nowrap()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tokens.tab_active)
|
||||
.opacity(0.75)
|
||||
@@ -68,10 +71,10 @@ impl Render for DragPanelPreview {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||
/// The zoom affordance `panel` offers, if any.
|
||||
///
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
let panel = group.active_panel()?;
|
||||
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||
panel
|
||||
.zoomable(cx)
|
||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||
@@ -79,16 +82,17 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
}
|
||||
|
||||
/// The left-most, top-most tab group in a container.
|
||||
///
|
||||
/// A left dock's collapse button lives in this group.
|
||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||
PaneRef::Tiles { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-most, top-most tab group.
|
||||
///
|
||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
@@ -98,7 +102,53 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
gpui::Axis::Horizontal => children.last(),
|
||||
}
|
||||
.and_then(right_top_group),
|
||||
PaneRef::Tiles { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group is this one, if any.
|
||||
///
|
||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||
fn dock_root_group(
|
||||
shared: &SkinShared,
|
||||
group: &TabGroupContext,
|
||||
cx: &App,
|
||||
) -> Option<DockPlacement> {
|
||||
let area = shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
|
||||
[DockPlacement::Bottom, DockPlacement::Right]
|
||||
.into_iter()
|
||||
.find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.root().id() == group.node())
|
||||
})
|
||||
}
|
||||
|
||||
/// Close `panel`, removing its dock instead when base would refuse an empty one.
|
||||
///
|
||||
/// A bottom or right dock's only panel is that case, so the whole dock goes.
|
||||
fn close_panel(
|
||||
shared: &SkinShared,
|
||||
group: &TabGroupContext,
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if !panel.closable(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dock = (group.panels().len() <= 1)
|
||||
.then(|| dock_root_group(shared, group, cx))
|
||||
.flatten();
|
||||
|
||||
match dock {
|
||||
Some(placement) => {
|
||||
_ = shared.area().update(cx, |area, cx| {
|
||||
area.remove_dock(placement, window, cx);
|
||||
});
|
||||
}
|
||||
None => group.close(panel.panel_id(cx), window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,26 +194,14 @@ impl SignedTabGroupSkin {
|
||||
left == group.node() && group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group is this one, if any.
|
||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
[DockPlacement::Bottom, DockPlacement::Right]
|
||||
.into_iter()
|
||||
.find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.root().id() == group.node())
|
||||
})
|
||||
}
|
||||
|
||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||
///
|
||||
/// A locked group never is, a bottom or right dock root always is.
|
||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
}
|
||||
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
|
||||
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
|
||||
return None;
|
||||
}
|
||||
group.drag_panel(ix, cx)
|
||||
@@ -198,7 +236,7 @@ impl SignedTabGroupSkin {
|
||||
DockPlacement::Bottom => area
|
||||
.layout(DockPlacement::Bottom)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Center => None,
|
||||
DockPlacement::Center => return None,
|
||||
};
|
||||
if designated != Some(group.node()) {
|
||||
return None;
|
||||
@@ -233,15 +271,22 @@ impl SignedTabGroupSkin {
|
||||
}
|
||||
|
||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||
/// Always rendered, disabled at the strip ends or when collapsed.
|
||||
fn render_prev_next_tab_buttons(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
_cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
///
|
||||
/// Hidden by default, and only offered for crowded strips where the active
|
||||
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
||||
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let panels_len = group.panels().len();
|
||||
|
||||
let hidden = SettingsStore::try_global(cx)
|
||||
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
|
||||
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
|
||||
|
||||
if !navigation_buttons_visible(hidden, panels_len) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let prev_enabled = !collapsed && active_ix > 0;
|
||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||
|
||||
@@ -273,100 +318,10 @@ impl SignedTabGroupSkin {
|
||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
if group.is_collapsed() {
|
||||
return div();
|
||||
}
|
||||
|
||||
let zoomed = group.is_zoomed();
|
||||
let handle = group.active_panel().and_then(PanelHandle::of);
|
||||
let control = zoom_control(group, cx);
|
||||
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
||||
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
||||
// A bottom or right dock's only panel cannot close through the group.
|
||||
// The close item is offered, the skin removes the whole dock instead.
|
||||
let closable = group.is_closable()
|
||||
|| (self.is_dock_root_group(group, cx).is_some()
|
||||
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
||||
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
||||
let panel = handle.map(|handle| handle.panel());
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.occlude()
|
||||
.when_some(buttons, |this, buttons| {
|
||||
this.children(
|
||||
buttons
|
||||
.into_iter()
|
||||
.map(|button| button.small().ghost().tab_stop(false)),
|
||||
)
|
||||
})
|
||||
.map(|this| {
|
||||
let value = if zoomed {
|
||||
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
|
||||
} else if toolbar_zoom {
|
||||
Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((id, icon, tooltip)) = value {
|
||||
this.child(
|
||||
Button::new(id)
|
||||
.icon(icon)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip_with_action(tooltip, &ToggleZoom, None)
|
||||
.selected(zoomed)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
move |_, window, cx| group.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.dropdown_menu(move |menu, window, cx| {
|
||||
menu.when_some(panel.clone(), |menu, panel| {
|
||||
panel.dropdown_menu(menu, window, cx)
|
||||
})
|
||||
.separator()
|
||||
.menu_with_disabled(
|
||||
if zoomed {
|
||||
t("Dock.Zoom Out")
|
||||
} else {
|
||||
t("Dock.Zoom In")
|
||||
},
|
||||
Box::new(ToggleZoom),
|
||||
!menu_zoom,
|
||||
)
|
||||
.when(closable, |menu| {
|
||||
menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
|
||||
})
|
||||
})
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One tab of the pill strip.
|
||||
/// While collapsed, tabs lose the active style and all interactions.
|
||||
/// The strip is also how a closed bottom dock is opened again.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
@@ -382,25 +337,73 @@ impl SignedTabGroupSkin {
|
||||
let droppable = group.is_droppable();
|
||||
let drag = self.tab_drag(group, ix, cx);
|
||||
let handle = PanelHandle::of(&panel);
|
||||
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
||||
|
||||
let closable = !collapsed
|
||||
&& (group.is_closable()
|
||||
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
||||
|
||||
// Whether a double-click zooms this tab in.
|
||||
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
||||
|
||||
Tab::new(ix)
|
||||
.h_6()
|
||||
.px_3()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.group(TAB_HOVER_GROUP)
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.flex_shrink_0()
|
||||
.h_7()
|
||||
.min_w_40()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().foreground)
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
}),
|
||||
)
|
||||
.when(closable, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
||||
})
|
||||
.when(!active, |this| {
|
||||
this.invisible()
|
||||
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
||||
})
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip(t("Dock.Close"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let shared = self.shared.clone();
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
close_panel(&shared, &group, &panel, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
// Pill style, the selected tab is the filled pill, others show only on hover.
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
@@ -412,15 +415,15 @@ impl SignedTabGroupSkin {
|
||||
if active {
|
||||
this
|
||||
} else {
|
||||
this.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary_hover)
|
||||
this.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tab)
|
||||
}
|
||||
})
|
||||
.selected(active)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let area = self.shared.area().clone();
|
||||
move |_, window, cx| {
|
||||
move |event, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||
@@ -428,7 +431,15 @@ impl SignedTabGroupSkin {
|
||||
_ = area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A double-click zooms the tab's panel
|
||||
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.when(!collapsed, |this| {
|
||||
@@ -526,15 +537,13 @@ impl SignedTabGroupSkin {
|
||||
|
||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let control = zoom_control(group, cx);
|
||||
let control = group
|
||||
.active_panel()
|
||||
.and_then(|panel| panel_zoom_control(panel, cx));
|
||||
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
// Base refuses an empty dock, so closing its only panel removes the dock.
|
||||
let dock_to_remove = (group.panels().len() <= 1)
|
||||
.then(|| self.is_dock_root_group(group, cx))
|
||||
.flatten();
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||
@@ -543,14 +552,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
// A collapsed group has no content, so these actions are not registered.
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
// A panel with no zoom control is not zoomed in by the keybinding.
|
||||
// Zooming out is never refused.
|
||||
// Otherwise a zoomed panel that lost its control would strand the user.
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -564,18 +569,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
if !panel.closable(cx) {
|
||||
return;
|
||||
}
|
||||
let panel = panel.panel_id(cx);
|
||||
match dock_to_remove {
|
||||
Some(placement) => {
|
||||
_ = shared.area().update(cx, |area, cx| {
|
||||
area.remove_dock(placement, window, cx);
|
||||
});
|
||||
}
|
||||
None => group.close(panel, window, cx),
|
||||
}
|
||||
close_panel(&shared, &group, panel, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -613,17 +607,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// On macOS the traffic lights overlay the window's top-left corner.
|
||||
// Only the tab bar that sits under them reserves the space.
|
||||
// That is the center's top-left group when the left dock is closed or absent.
|
||||
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||
// tab bar under them reserves the space. That is the center's top-left group
|
||||
// when the left dock is closed or absent, and the zoomed group otherwise:
|
||||
// a zoom fills the whole area, hiding the docks the frame would draw.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
match area.zoomed_group() {
|
||||
Some(zoomed) => zoomed == group.node(),
|
||||
None => {
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view.
|
||||
@@ -709,7 +709,16 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
.child(
|
||||
Tabs::new("tab-bar")
|
||||
.px(px(-1.))
|
||||
.h(TAB_BAR_HEIGHT)
|
||||
// A collapsed group stands in for a closed bottom dock,
|
||||
// which base gives only its reserved strip. The bar keeps
|
||||
// its own height there rather than the title-bar height,
|
||||
// so it fits instead of being clipped away.
|
||||
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
||||
// Only a collapsed (bottom) dock tags its bar, so the
|
||||
// selector is unique when a test measures it.
|
||||
.when(collapsed, |this| {
|
||||
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
@@ -763,7 +772,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
.and_then(PanelHandle::of)
|
||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||
)
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.children(right_dock_button),
|
||||
)
|
||||
}),
|
||||
@@ -833,3 +841,123 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
Render, TestAppContext, Window, px,
|
||||
};
|
||||
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
|
||||
|
||||
use super::{COLLAPSED_TAB_BAR_SELECTOR, navigation_buttons_visible};
|
||||
use crate::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
|
||||
struct Probe {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for Probe {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Probe {
|
||||
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Probe {}
|
||||
|
||||
impl Focusable for Probe {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Base reserves only `gpui_base::dock::CLOSED_BOTTOM_STRIP` for a closed
|
||||
/// bottom dock, so the tab bar drawn in place of one has to shrink to that
|
||||
/// height; at the full title-bar height its bottom was clipped away.
|
||||
#[gpui::test]
|
||||
fn a_collapsed_bottom_dock_bar_fits_the_reserved_strip(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
});
|
||||
let (area, cx) = cx.add_window_view(|window, cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
DockArea::new("test", None, window, cx).with_renderer(skin)
|
||||
});
|
||||
|
||||
cx.update(|window, cx| {
|
||||
let center = cx.new(Probe::new);
|
||||
let bottom = cx.new(Probe::new);
|
||||
area.update(cx, |area, cx| {
|
||||
area.set_center(
|
||||
DockLayout::tabs().panel_view(panel_handle(center), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
area.set_dock(
|
||||
DockPlacement::Bottom,
|
||||
DockLayout::tabs().panel_view(panel_handle(bottom), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// Open, nothing is collapsed, so no bar carries the selector.
|
||||
assert!(cx.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR).is_none());
|
||||
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
let bounds = cx
|
||||
.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR)
|
||||
.expect("a collapsed bottom dock draws its stand-in tab bar");
|
||||
// `gpui_base::dock::CLOSED_BOTTOM_STRIP`, which the engine does not
|
||||
// re-export. The bar used to be `TAB_BAR_HEIGHT` here and its bottom was
|
||||
// clipped away; it now has to fit inside what base reserves.
|
||||
let reserved_strip = px(29.);
|
||||
assert!(
|
||||
bounds.size.height <= reserved_strip,
|
||||
"the collapsed bar is {}px tall, more than the {}px strip base reserves",
|
||||
bounds.size.height,
|
||||
reserved_strip,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_buttons_follow_the_setting_and_tab_count() {
|
||||
// Hidden by default, no matter how many tabs there are.
|
||||
assert!(!navigation_buttons_visible(true, 1));
|
||||
assert!(!navigation_buttons_visible(true, 20));
|
||||
|
||||
// When not hidden they still only appear for a crowded strip.
|
||||
assert!(!navigation_buttons_visible(false, 10));
|
||||
assert!(navigation_buttons_visible(false, 11));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Div, DragMoveEvent, Empty, InteractiveElement as _,
|
||||
IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
||||
Size, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, px,
|
||||
};
|
||||
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::{
|
||||
ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::t;
|
||||
use crate::tab_panel::panel_title;
|
||||
|
||||
/// How far a resize handle sticks out past the tile's edge.
|
||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
||||
|
||||
/// The payload a tile drag carries, so one canvas ignores another's drags.
|
||||
#[derive(Clone)]
|
||||
struct DragMoving(NodeId);
|
||||
|
||||
impl Render for DragMoving {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload a tile resize carries, for the same reason.
|
||||
#[derive(Clone)]
|
||||
struct DragResizing(NodeId);
|
||||
|
||||
impl Render for DragResizing {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// One tiles canvas's appearance.
|
||||
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
|
||||
pub(crate) struct SignedTilesSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl SignedTilesSkin {
|
||||
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
|
||||
Self {
|
||||
shared,
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge or corner handle.
|
||||
fn resize_handle(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
id: &'static str,
|
||||
side: ResizeSide,
|
||||
build: impl FnOnce(Stateful<Div>) -> Stateful<Div>,
|
||||
) -> Stateful<Div> {
|
||||
let node = tile.node();
|
||||
|
||||
build(div().id(id).absolute())
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
tile.begin_resize(side, event.position, window, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.on_drag(DragResizing(node), |drag, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new(|_| drag.clone())
|
||||
})
|
||||
.on_drag_move({
|
||||
let tile = tile.clone();
|
||||
move |event: &DragMoveEvent<DragResizing>, window, cx| {
|
||||
if event.drag(cx).0 != node {
|
||||
return;
|
||||
}
|
||||
tile.resize_to(event.event.position, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
|
||||
/// They use click handlers, the zoom and close actions target a focused tab group.
|
||||
fn render_tile_controls(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let handle = PanelHandle::of(tile.panel());
|
||||
let control = handle.and_then(|handle| handle.zoom_control(cx));
|
||||
let zoomed = tile.is_zoomed();
|
||||
let toolbar_zoom =
|
||||
tile.is_zoomable() && control.is_some_and(|control| control.toolbar_visible());
|
||||
let menu_zoom = tile.is_zoomable() && control.is_some_and(|control| control.menu_visible());
|
||||
let closable = tile.is_closable();
|
||||
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
||||
let panel = handle.map(|handle| handle.panel());
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_shrink_0()
|
||||
.occlude()
|
||||
.when_some(buttons, |this, buttons| {
|
||||
this.children(
|
||||
buttons
|
||||
.into_iter()
|
||||
.map(|button| button.xsmall().ghost().tab_stop(false)),
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
match (zoomed, toolbar_zoom) {
|
||||
(true, _) => Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))),
|
||||
(false, true) => Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))),
|
||||
(false, false) => None,
|
||||
},
|
||||
|this, (id, icon, tooltip)| {
|
||||
this.child(
|
||||
Button::new(id)
|
||||
.icon(icon)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip(tooltip)
|
||||
.selected(zoomed)
|
||||
.on_click({
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.dropdown_menu({
|
||||
let tile = tile.clone();
|
||||
move |menu, window, cx| {
|
||||
menu.when_some(panel.clone(), |menu, panel| {
|
||||
panel.dropdown_menu(menu, window, cx)
|
||||
})
|
||||
.separator()
|
||||
.item(
|
||||
PopupMenuItem::new(match zoomed {
|
||||
true => t("Dock.Zoom Out"),
|
||||
false => t("Dock.Zoom In"),
|
||||
})
|
||||
.disabled(!menu_zoom && !zoomed)
|
||||
.on_click({
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
.when(closable, |menu| {
|
||||
menu.separator()
|
||||
.item(PopupMenuItem::new(t("Dock.Close")).on_click({
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.close(window, cx)
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
.anchor(gpui::Anchor::TopRight),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TilesRenderer for SignedTilesSkin {
|
||||
fn frame(&self, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("tiles")
|
||||
.relative()
|
||||
.size_full()
|
||||
.bg(cx.theme().tokens.tiles)
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.overflow_scroll()
|
||||
}
|
||||
|
||||
fn tile_frame(&self, tile: &TileContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id(("tile", tile.panel_id().as_u64()))
|
||||
.occlude()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().tile_radius)
|
||||
// Room for the title bar, which overlays the top padding.
|
||||
// Base draws the panel as a plain child, this keeps them apart.
|
||||
.pt(DRAG_BAR_HEIGHT)
|
||||
// Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
|
||||
.when(tile.is_zoomed(), |this| this.size_full())
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.bring_to_front(window, cx)
|
||||
})
|
||||
// A gesture can end anywhere, so both mouse-up hooks run.
|
||||
// Each is a no-op unless this tile is the one that moved.
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| {
|
||||
tile.end_move(window, cx);
|
||||
tile.end_resize(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_up_out(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| {
|
||||
tile.end_move(window, cx);
|
||||
tile.end_resize(window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let node = tile.node();
|
||||
let handle = PanelHandle::of(tile.panel());
|
||||
let title_style = handle.and_then(|handle| handle.title_style(cx));
|
||||
|
||||
h_flex()
|
||||
.id("drag-bar")
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h(DRAG_BAR_HEIGHT)
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.pl_3()
|
||||
.pr_2()
|
||||
.when_some(title_style, |this, style| {
|
||||
this.bg(style.background).text_color(style.foreground)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_16()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(panel_title(tile.panel(), window, cx)),
|
||||
)
|
||||
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
|
||||
.child(self.render_tile_controls(tile, window, cx))
|
||||
// A zoomed tile is not at its stored bounds, so moving it would mean nothing.
|
||||
.when(!tile.is_zoomed(), |this| {
|
||||
this.cursor_grab()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
tile.begin_move(event.position, window, cx);
|
||||
}
|
||||
})
|
||||
.on_drag(DragMoving(node), |drag, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new(|_| drag.clone())
|
||||
})
|
||||
.on_drag_move({
|
||||
let tile = tile.clone();
|
||||
move |event: &DragMoveEvent<DragMoving>, window, cx| {
|
||||
if event.drag(cx).0 != node {
|
||||
return;
|
||||
}
|
||||
tile.move_to(event.event.position, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_resize_handles(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let bounds = tile.bounds();
|
||||
|
||||
// A passive full-tile box, so handles sit against the tile, not its flow neighbours.
|
||||
// It registers no interaction, so it does not shadow the panel underneath.
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.child(
|
||||
self.resize_handle(tile, "left-resize-handle", ResizeSide::Left, |this| {
|
||||
this.cursor_ew_resize()
|
||||
.top_0()
|
||||
.left(HANDLE_OFFSET)
|
||||
.w(HANDLE_SIZE)
|
||||
.h(bounds.size.height)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
self.resize_handle(tile, "right-resize-handle", ResizeSide::Right, |this| {
|
||||
this.cursor_ew_resize()
|
||||
.top_0()
|
||||
.right(HANDLE_OFFSET)
|
||||
.w(HANDLE_SIZE)
|
||||
.h(bounds.size.height)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
self.resize_handle(tile, "top-resize-handle", ResizeSide::Top, |this| {
|
||||
this.cursor_ns_resize()
|
||||
.left_0()
|
||||
.top(HANDLE_OFFSET)
|
||||
.w(bounds.size.width)
|
||||
.h(HANDLE_SIZE)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
self.resize_handle(tile, "bottom-resize-handle", ResizeSide::Bottom, |this| {
|
||||
this.cursor_ns_resize()
|
||||
.left_0()
|
||||
.bottom(HANDLE_OFFSET)
|
||||
.w(bounds.size.width)
|
||||
.h(HANDLE_SIZE)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::ResizeCorner)
|
||||
.size_3()
|
||||
.absolute()
|
||||
.right(px(1.))
|
||||
.bottom(px(1.))
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.5)),
|
||||
)
|
||||
.child(self.resize_handle(
|
||||
tile,
|
||||
"corner-resize-handle",
|
||||
ResizeSide::BottomRight,
|
||||
|this| {
|
||||
this.cursor_nwse_resize()
|
||||
.right(HANDLE_OFFSET)
|
||||
.bottom(HANDLE_OFFSET)
|
||||
.size_3()
|
||||
},
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
|
||||
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
h_flex()
|
||||
.id(("tile-panel", tile.panel_id().as_u64()))
|
||||
.overflow_hidden()
|
||||
.size_full()
|
||||
}
|
||||
|
||||
/// The canvas scrollbar, as an overlay.
|
||||
/// Placed inside the frame it would end up underneath every tile.
|
||||
fn render_overlay(
|
||||
&self,
|
||||
content: Size<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
Some(
|
||||
Scrollbar::new(&self.scroll_handle)
|
||||
.scroll_size(content)
|
||||
.when_some(self.shared.tiles_scrollbar_mode(), |this, mode| {
|
||||
this.mode(mode)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn grid_size(&self, cx: &App) -> Pixels {
|
||||
cx.theme().tile_grid_size
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
|
||||
|
||||
use crate::TAB_BAR_HEIGHT;
|
||||
|
||||
/// The standard width of a window control button.
|
||||
const CONTROL_WIDTH: f32 = 34.;
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "metadata"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_state = { path = "../signed_state" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,9 @@
|
||||
mod profile;
|
||||
|
||||
use gpui::{App, AppContext};
|
||||
pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore};
|
||||
|
||||
/// Initialize the global profile store.
|
||||
pub fn init(cx: &mut App) {
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use flume::Sender;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_state::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const REQUEST_CHUNK: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
// Falls back to `name`, then a shortened npub.
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name.trim().to_owned());
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata.name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key))
|
||||
}
|
||||
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| SharedString::from(p.clone()))
|
||||
}
|
||||
|
||||
/// The newest profile per author from metadata events.
|
||||
fn latest_profiles(events: impl IntoIterator<Item = Event>) -> Vec<Self> {
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((created_at, _)) if *created_at >= event.created_at => {}
|
||||
_ => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
latest.insert(event.pubkey, (event.created_at, metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest
|
||||
.into_iter()
|
||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for these authors may have changed in the local database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProfileEvent {
|
||||
Updated(Vec<PublicKey>),
|
||||
}
|
||||
|
||||
/// Batches metadata requests into relay syncs feeding the local database.
|
||||
pub struct ProfileStore {
|
||||
in_flight: RefCell<HashSet<PublicKey>>,
|
||||
sender: Sender<PublicKey>,
|
||||
_task: Task<Result<(), anyhow::Error>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||
|
||||
impl Global for GlobalProfileStore {}
|
||||
|
||||
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
||||
|
||||
impl ProfileStore {
|
||||
pub(crate) fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalProfileStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalProfileStore(entity));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||
this.emit_updated(authors.clone(), cx);
|
||||
}
|
||||
});
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
|
||||
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 authors: Vec<PublicKey> = batch.drain().collect();
|
||||
|
||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||
let opts = SyncOptions::default();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(chunk.iter().copied());
|
||||
|
||||
if let Err(e) = sync_bootstrap_only(&client, filter, opts).await {
|
||||
log::warn!("profile fetch failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit even when the sync failed, the database may hold newer data.
|
||||
this.update(cx, |this, cx| {
|
||||
this.emit_updated(authors, cx);
|
||||
})?;
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
in_flight: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
_task: task,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a batched relay sync for keys without one in flight.
|
||||
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>) {
|
||||
let mut queue = Vec::new();
|
||||
{
|
||||
let mut in_flight = self.in_flight.borrow_mut();
|
||||
|
||||
for key in keys {
|
||||
if in_flight.insert(key) {
|
||||
queue.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for public_key in queue {
|
||||
if let Err(e) = self.sender.send(public_key) {
|
||||
log::warn!("failed to queue profile fetch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Releases the in-flight keys so future fetches can be queued again.
|
||||
fn emit_updated(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||
if authors.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut in_flight = self.in_flight.borrow_mut();
|
||||
for author in &authors {
|
||||
in_flight.remove(author);
|
||||
}
|
||||
}
|
||||
|
||||
cx.emit(ProfileEvent::Updated(authors));
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile cache bound to one panel or screen.
|
||||
pub struct ProfileDirectory {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
pending: RefCell<HashSet<PublicKey>>,
|
||||
tasks: Vec<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl ProfileDirectory {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let store = ProfileStore::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription =
|
||||
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||
ProfileEvent::Updated(authors) => this.refresh(authors, cx),
|
||||
});
|
||||
|
||||
Self {
|
||||
profiles: HashMap::new(),
|
||||
pending: RefCell::new(HashSet::new()),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: vec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve keys from the local database, queue the rest for a relay sync.
|
||||
pub fn track<K>(&mut self, keys: K, cx: &mut Context<Self>)
|
||||
where
|
||||
K: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let missing: Vec<PublicKey> = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
!self.profiles.contains_key(key)
|
||||
&& !self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.load(missing, true, cx);
|
||||
}
|
||||
|
||||
/// The profile for a key, or a placeholder that queues a relay sync.
|
||||
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
}
|
||||
|
||||
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||
let store = ProfileStore::global(cx);
|
||||
store.read(cx).fetch([*public_key]);
|
||||
}
|
||||
|
||||
Profile::new(*public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Refreshes profiles for keys that are already tracked.
|
||||
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>) {
|
||||
let tracked: Vec<PublicKey> = authors
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| {
|
||||
self.profiles.contains_key(key)
|
||||
|| self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tracked.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.load(tracked, false, cx);
|
||||
}
|
||||
|
||||
/// Loads profiles from the local database, queueing a relay sync for any missing keys.
|
||||
fn load(&mut self, keys: Vec<PublicKey>, queue_sync: bool, cx: &mut Context<Self>) {
|
||||
// Accept events that arrive while the database query is running.
|
||||
self.with_pending(|pending| {
|
||||
pending.extend(keys.iter().copied());
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
let query_keys = keys.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let authors = query_keys.iter().copied();
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
Ok::<_, anyhow::Error>(Profile::latest_profiles(events))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let loaded = work.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let remaining = match loaded {
|
||||
Ok(profiles) => {
|
||||
let found: HashSet<PublicKey> =
|
||||
profiles.iter().map(Profile::public_key).collect();
|
||||
this.insert_loaded(profiles, cx);
|
||||
keys.into_iter()
|
||||
.filter(|key| !found.contains(key))
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to load profiles from the database: {e}");
|
||||
keys
|
||||
}
|
||||
};
|
||||
|
||||
if queue_sync && !remaining.is_empty() {
|
||||
let store = ProfileStore::global(cx);
|
||||
store.read(cx).fetch(remaining);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
// Inserts database results, replacing anything loaded earlier.
|
||||
fn insert_loaded(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
||||
let mut inserted = false;
|
||||
|
||||
for profile in profiles {
|
||||
let public_key = profile.public_key();
|
||||
self.profiles.insert(public_key, profile);
|
||||
self.with_pending(|pending| pending.remove(&public_key));
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
if inserted {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending<R, T>(&self, then: T) -> R
|
||||
where
|
||||
T: FnOnce(&mut HashSet<PublicKey>) -> R,
|
||||
{
|
||||
let mut pending = self.pending.borrow_mut();
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,7 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
/// On Windows, this is `%APPDATA%\Signed`.
|
||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Returns the current user's home directory.
|
||||
pub fn home_dir() -> PathBuf {
|
||||
fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("failed to determine home directory")
|
||||
}
|
||||
|
||||
@@ -42,8 +41,7 @@ pub fn documents_dir() -> PathBuf {
|
||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
fn config_dir() -> &'static PathBuf {
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
dirs::config_dir()
|
||||
@@ -62,8 +60,7 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the data directory.
|
||||
pub fn data_dir() -> &'static PathBuf {
|
||||
fn data_dir() -> &'static PathBuf {
|
||||
CURRENT_DATA_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir()
|
||||
@@ -93,12 +90,15 @@ pub fn nostr_dir() -> &'static PathBuf {
|
||||
}
|
||||
|
||||
/// Returns the path to the local git clone cache, the grasp mirrors.
|
||||
///
|
||||
/// The mirrors are disposable and re-cloned from their grasp server on
|
||||
/// demand, so the cache lives in the OS temp directory for the system to
|
||||
/// reclaim.
|
||||
pub fn repos_dir() -> &'static PathBuf {
|
||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||
REPOS_DIR.get_or_init(|| std::env::temp_dir().join(APP_NAME_LOWERCASE).join("repos"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `settings.json` file.
|
||||
pub fn settings_file() -> &'static PathBuf {
|
||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||
|
||||
@@ -2,29 +2,32 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The default grasp servers,
|
||||
/// offered while the user has not published a grasp list.
|
||||
/// The default grasp servers, offered while the user has not published a grasp list.
|
||||
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||
"wss://relay.ngit.dev",
|
||||
"wss://gitnostr.com",
|
||||
"wss://git.shakespeare.diy",
|
||||
];
|
||||
|
||||
/// How the application picks its appearance.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the system appearance, light or dark, at runtime.
|
||||
#[default]
|
||||
System,
|
||||
/// Always use the light theme.
|
||||
Light,
|
||||
/// Always use the dark theme.
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Theme configuration,
|
||||
/// fields mirror the gpui-component `Theme` surface customized at startup.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventFetchingStrategy {
|
||||
/// Only the relays declared in the repository announcement.
|
||||
Curated,
|
||||
/// Repository relays plus every maintainer's NIP-65 relays.
|
||||
#[default]
|
||||
Uncensored,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ThemeSettings {
|
||||
@@ -42,7 +45,7 @@ pub struct ThemeSettings {
|
||||
pub radius_lg: f32,
|
||||
/// Whether focused controls draw a ring outside their border.
|
||||
pub focus_ring: bool,
|
||||
/// Whether to render shadows.
|
||||
/// Whether to apply a shadow to elements.
|
||||
pub shadow: bool,
|
||||
}
|
||||
|
||||
@@ -53,15 +56,30 @@ impl Default for ThemeSettings {
|
||||
dark_theme: "Signed Dark".into(),
|
||||
font_size: 16.0,
|
||||
mono_font_size: 13.0,
|
||||
radius: 2.0,
|
||||
radius_lg: 6.0,
|
||||
radius: 4.0,
|
||||
radius_lg: 8.0,
|
||||
focus_ring: false,
|
||||
shadow: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default grasp server settings.
|
||||
/// Tab bar chrome.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TabBarSettings {
|
||||
/// Hide the previous and next tab buttons.
|
||||
pub hide_navigation_buttons: bool,
|
||||
}
|
||||
|
||||
impl Default for TabBarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hide_navigation_buttons: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GraspServersSettings {
|
||||
@@ -80,23 +98,21 @@ impl Default for GraspServersSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Local repository scanning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LocalReposSettings {
|
||||
/// The directories scanned for local git repositories,
|
||||
/// defaults to the user's Desktop and Documents folders.
|
||||
/// Defaults:
|
||||
///
|
||||
/// - `~/Desktop`
|
||||
/// - `~/Documents`
|
||||
pub scan_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn default_scan_paths() -> Vec<PathBuf> {
|
||||
vec![paths::desktop_dir(), paths::documents_dir()]
|
||||
}
|
||||
|
||||
impl Default for LocalReposSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scan_paths: default_scan_paths(),
|
||||
scan_paths: vec![paths::desktop_dir(), paths::documents_dir()],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +124,6 @@ impl Default for LocalReposSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutRecord {
|
||||
/// Local folder of the checkout.
|
||||
pub path: PathBuf,
|
||||
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
||||
pub addr: String,
|
||||
@@ -116,16 +131,13 @@ pub struct CheckoutRecord {
|
||||
pub last_used: u64,
|
||||
}
|
||||
|
||||
/// Remembered local checkouts, see [`CheckoutRecord`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutsSettings {
|
||||
/// The remembered records.
|
||||
/// The latest use of a path and repo pair replaces the older record.
|
||||
pub records: Vec<CheckoutRecord>,
|
||||
}
|
||||
|
||||
/// The create-repository dialog.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CreateRepositorySettings {
|
||||
@@ -137,17 +149,13 @@ pub struct CreateRepositorySettings {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// How the application picks its appearance.
|
||||
pub appearance: AppearanceMode,
|
||||
/// Theme configuration.
|
||||
pub event_fetching: EventFetchingStrategy,
|
||||
pub theme: ThemeSettings,
|
||||
/// Default grasp servers.
|
||||
pub tab_bar: TabBarSettings,
|
||||
pub grasp_servers: GraspServersSettings,
|
||||
/// Local repository scanning.
|
||||
pub local_repos: LocalReposSettings,
|
||||
/// Remembered local checkouts.
|
||||
pub checkouts: CheckoutsSettings,
|
||||
/// The create-repository dialog.
|
||||
pub create_repository: CreateRepositorySettings,
|
||||
}
|
||||
|
||||
@@ -155,34 +163,11 @@ pub struct Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_app_conventions() {
|
||||
let settings = Settings::default();
|
||||
assert_eq!(settings.appearance, AppearanceMode::System);
|
||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||
assert_eq!(settings.theme.dark_theme, "Signed Dark");
|
||||
assert_eq!(settings.theme.font_size, 16.0);
|
||||
assert_eq!(settings.theme.mono_font_size, 13.0);
|
||||
assert_eq!(settings.theme.radius, 2.0);
|
||||
assert_eq!(settings.theme.radius_lg, 6.0);
|
||||
assert!(!settings.theme.focus_ring);
|
||||
assert!(!settings.theme.shadow);
|
||||
assert_eq!(
|
||||
settings.grasp_servers.default_servers,
|
||||
DEFAULT_GRASP_SERVERS.map(String::from).to_vec()
|
||||
);
|
||||
assert_eq!(settings.local_repos.scan_paths.len(), 2);
|
||||
assert_eq!(
|
||||
settings.local_repos.scan_paths,
|
||||
vec![paths::desktop_dir(), paths::documents_dir()]
|
||||
);
|
||||
assert_eq!(settings.create_repository.default_folder, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip_preserves_everything() {
|
||||
let settings = Settings {
|
||||
appearance: AppearanceMode::Dark,
|
||||
event_fetching: EventFetchingStrategy::Curated,
|
||||
theme: ThemeSettings {
|
||||
radius: 8.0,
|
||||
..Default::default()
|
||||
@@ -198,18 +183,14 @@ mod tests {
|
||||
assert_eq!(parsed, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_fall_back_to_defaults() {
|
||||
let settings: Settings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(settings, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_merges_with_defaults() {
|
||||
let settings: Settings =
|
||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||
assert_eq!(settings.theme.radius, 4.0);
|
||||
// Unset fields fall back to their defaults, including the fetching strategy.
|
||||
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
|
||||
// The rest of the theme and the other groups keep their defaults.
|
||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||
@@ -217,18 +198,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_serializes_to_snake_case_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::System).unwrap(),
|
||||
"\"system\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::Light).unwrap(),
|
||||
"\"light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::Dark).unwrap(),
|
||||
"\"dark\""
|
||||
);
|
||||
fn event_fetching_uses_snake_case() {
|
||||
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
|
||||
assert_eq!(json, r#""curated""#);
|
||||
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
|
||||
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ impl SettingsStore {
|
||||
cx.global::<GlobalSettingsStore>().0.clone()
|
||||
}
|
||||
|
||||
/// Install the store as a global.
|
||||
/// Retrieve the global settings store if one has been installed.
|
||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalSettingsStore>()
|
||||
.map(|store| store.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalSettingsStore(entity));
|
||||
}
|
||||
@@ -103,8 +108,6 @@ impl SettingsStore {
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use gpui::{AppContext, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
|
||||
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -133,16 +136,6 @@ mod tests {
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_loads_defaults() {
|
||||
let path = temp_settings_path();
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
|
||||
let settings = SettingsStore::load(&path);
|
||||
assert_eq!(settings, Settings::default());
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let path = temp_settings_path();
|
||||
@@ -160,21 +153,4 @@ mod tests {
|
||||
assert_eq!(SettingsStore::load(&path), expected);
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn edit_mutates_and_persists(cx: &mut TestAppContext) {
|
||||
let path = temp_settings_path();
|
||||
cleanup(&path);
|
||||
|
||||
let store = cx.update(|cx| cx.new(|cx| SettingsStore::new(path.clone(), cx)));
|
||||
cx.read(|cx| assert_eq!(store.read(cx).settings(), &Settings::default()));
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius = 12.0, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| assert_eq!(store.read(cx).settings().theme.radius, 12.0));
|
||||
assert_eq!(SettingsStore::load(&path).theme.radius, 12.0);
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,4 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
utils = { path = "../utils" }
|
||||
|
||||
@@ -1,39 +1,98 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||
///
|
||||
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
|
||||
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
||||
pub type RepoAddr = Coordinate;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct RepoAddr(Coordinate);
|
||||
|
||||
/// Build the address of a NIP-34 repository announcement.
|
||||
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
||||
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
||||
}
|
||||
impl RepoAddr {
|
||||
pub fn new(owner: PublicKey, identifier: impl Into<String>) -> Self {
|
||||
Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier))
|
||||
}
|
||||
|
||||
/// Derive a repository identifier from a display name
|
||||
pub fn identifier_from_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '/' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn identifier_from_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '/' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
pub fn kind(&self) -> Kind {
|
||||
self.0.kind
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_from_name_slugs_like_gitworkshop() {
|
||||
assert_eq!(identifier_from_name("My Repo"), "My-Repo");
|
||||
assert_eq!(identifier_from_name("my-repo"), "my-repo");
|
||||
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
|
||||
assert_eq!(identifier_from_name("a/b"), "a/b");
|
||||
assert_eq!(identifier_from_name("Café"), "Caf-");
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.0.public_key
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> &str {
|
||||
&self.0.identifier
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Coordinate {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn announcement_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(self.public_key())
|
||||
.identifier(self.identifier())
|
||||
}
|
||||
|
||||
pub fn state_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
.author(self.public_key())
|
||||
.identifier(self.identifier())
|
||||
}
|
||||
|
||||
pub fn activity_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(crate::filters::ACTIVITY_KINDS)
|
||||
.coordinate(&self.0)
|
||||
}
|
||||
|
||||
pub fn deletion_filters(&self) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.author(self.public_key()),
|
||||
Filter::new().kind(Kind::EventDeletion).coordinate(&self.0),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RepoAddr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RepoAddr {
|
||||
type Err = <Coordinate as FromStr>::Err;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Coordinate::from_str(s).map(RepoAddr)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Coordinate> for RepoAddr {
|
||||
fn from(coordinate: Coordinate) -> Self {
|
||||
RepoAddr(coordinate)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RepoAddr> for Coordinate {
|
||||
fn from(addr: RepoAddr) -> Self {
|
||||
addr.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
||||
///
|
||||
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
||||
/// not part of the NIP-34 draft, read support for interop.
|
||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||
|
||||
/// Whether a kind-1985 label event is a valid annotation of `root`.
|
||||
///
|
||||
/// The event references the root with a lowercase `e` tag,
|
||||
/// its author must be the root author or a maintainer.
|
||||
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||
if event.kind != Kind::Label {
|
||||
return false;
|
||||
}
|
||||
if event.pubkey != root.pubkey && !maintainers.contains(&event.pubkey) {
|
||||
return false;
|
||||
}
|
||||
let root_id = root.id.to_hex();
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||
}
|
||||
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||
/// it must also carry at least one `["l", "<value>", "#t"]` label.
|
||||
fn has_hashtag_labels(event: &Event) -> bool {
|
||||
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective hashtag labels of `root`,
|
||||
/// the `t` tags on the event itself, self-reported by its author,
|
||||
/// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
|
||||
///
|
||||
/// Labels are additive, so all valid label events contribute,
|
||||
/// there is no latest-wins semantics.
|
||||
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||
let mut labels: Vec<String> = root
|
||||
.tags
|
||||
.hashtags()
|
||||
.map(|hashtag| hashtag.to_string())
|
||||
.collect();
|
||||
|
||||
for event in label_events {
|
||||
if !label_targets_root(event, root, maintainers) || !has_hashtag_labels(event) {
|
||||
continue;
|
||||
}
|
||||
for tag in event.tags.iter() {
|
||||
let slice = tag.as_slice();
|
||||
if slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty() {
|
||||
let label = &slice[1];
|
||||
if !labels.contains(label) {
|
||||
labels.push(label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
/// Subject or title override of `root` from authorized kind-1985 label events,
|
||||
/// only label events in the `#subject` namespace count.
|
||||
///
|
||||
/// Returns `None` when no valid override exists.
|
||||
pub fn subject_override(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> Option<String> {
|
||||
label_events
|
||||
.iter()
|
||||
.filter(|event| label_targets_root(event, root, maintainers))
|
||||
.filter(|event| {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.as_slice() == ["L", "#subject"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
slice.len() >= 3
|
||||
&& slice[0] == "l"
|
||||
&& slice[2] == "#subject"
|
||||
&& !slice[1].is_empty()
|
||||
})
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||
})
|
||||
.and_then(|event| {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
(slice.len() >= 3
|
||||
&& slice[0] == "l"
|
||||
&& slice[2] == "#subject"
|
||||
&& !slice[1].is_empty())
|
||||
.then(|| slice[1].clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective hashtag labels and subject override of `root` in one pass,
|
||||
/// mirrors ngit's `get_labels_and_subject`.
|
||||
pub fn labels_and_subject(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
(
|
||||
labels(root, label_events, maintainers),
|
||||
subject_override(root, label_events, maintainers),
|
||||
)
|
||||
}
|
||||
|
||||
/// Effective cover note of `root`.
|
||||
///
|
||||
/// Returns `None` when no valid cover note exists.
|
||||
pub fn cover_note<'a>(
|
||||
root: &Event,
|
||||
cover_notes: &'a [Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> Option<&'a Event> {
|
||||
let root_id = root.id.to_hex();
|
||||
|
||||
cover_notes
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& (event.pubkey == root.pubkey || maintainers.contains(&event.pubkey))
|
||||
&& event.tags.iter().any(|tag| {
|
||||
tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)
|
||||
})
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys_from_hex(hex: &str) -> Keys {
|
||||
Keys::new(SecretKey::from_hex(hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn root_event() -> Event {
|
||||
signed(
|
||||
&keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::hashtag("bug")],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_take_inline_hashtags_and_external_label_events() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let labels_event = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "help-wanted", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let labels = labels(&root, &[labels_event], &[maintainer.public_key()]);
|
||||
|
||||
assert_eq!(labels, vec!["bug", "help-wanted"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_ignore_unauthorized_and_misnamed_events() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let stranger =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||
|
||||
// A stranger's label event is not authorized.
|
||||
let stranger_labels = signed(
|
||||
&stranger,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
// A valid author referencing a different event.
|
||||
let other_labels = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
Tag::parse([
|
||||
"e",
|
||||
"2222222222222222222222222222222222222222222222222222222222222222",
|
||||
])
|
||||
.expect("valid e tag"),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
// A valid author without the namespace declaration.
|
||||
let missing_namespace = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
labels(
|
||||
&root,
|
||||
&[stranger_labels, other_labels, missing_namespace],
|
||||
&[maintainer.public_key()]
|
||||
),
|
||||
vec!["bug"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_override_latest_authorized_event_wins() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let older = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "Old title", "#subject"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let newer = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "New title", "#subject"]).expect("valid l tag"),
|
||||
],
|
||||
300,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
subject_override(&root, &[newer, older], &[maintainer.public_key()]),
|
||||
Some("New title".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_note_latest_authorized_event_wins() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let stranger =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||
let older = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 200);
|
||||
let newer = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 300);
|
||||
let unauthorized = signed(&stranger, COVER_NOTE_KIND, vec![e_tag(&root)], 400);
|
||||
|
||||
let newer_id = newer.id;
|
||||
let events = [older, unauthorized, newer];
|
||||
let maintainers = [maintainer.public_key()];
|
||||
let note = cover_note(&root, &events, &maintainers);
|
||||
assert_eq!(note.map(|event| event.id), Some(newer_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_note_none_without_valid_events() {
|
||||
let root = root_event();
|
||||
|
||||
assert_eq!(cover_note(&root, &[], &[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::upstream::Upstream;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
pub event_id: EventId,
|
||||
pub id: String,
|
||||
pub owner: PublicKey,
|
||||
pub created_at: Timestamp,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub web: Vec<Url>,
|
||||
pub clone: Vec<Url>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
pub euc: Option<String>,
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
pub upstream: Option<Upstream>,
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
/// Lists forks of the base, the user's own first.
|
||||
pub fn forks_in<'a>(
|
||||
announcements: &'a [Announcement],
|
||||
base: &RepoAddr,
|
||||
base_euc: Option<&str>,
|
||||
user: Option<PublicKey>,
|
||||
) -> Vec<&'a Announcement> {
|
||||
let (mut own, mut others) = (Vec::new(), Vec::new());
|
||||
for announcement in announcements {
|
||||
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
||||
continue;
|
||||
}
|
||||
if Some(announcement.owner) == user {
|
||||
own.push(announcement);
|
||||
} else {
|
||||
others.push(announcement);
|
||||
}
|
||||
}
|
||||
own.into_iter().chain(others).collect()
|
||||
}
|
||||
|
||||
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
if event.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = event.tags.identifier()?;
|
||||
|
||||
let mut hashtags: Vec<String> = Vec::new();
|
||||
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
|
||||
|
||||
let mut name: Option<String> = None;
|
||||
let mut description: Option<String> = None;
|
||||
let mut web: Vec<Url> = Vec::new();
|
||||
let mut clone: Vec<Url> = Vec::new();
|
||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||
let mut euc: Option<String> = None;
|
||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||
let mut upstream: Option<Upstream> = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Name(value)) => name = Some(value),
|
||||
Ok(Nip34Tag::Description(value)) => description = Some(value),
|
||||
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
|
||||
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
|
||||
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
|
||||
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
|
||||
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if upstream.is_none() && tag.kind() == "u" {
|
||||
let values = tag.as_slice();
|
||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||
if !raw.is_empty() {
|
||||
upstream = Some(Upstream::parse(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
event_id: event.id,
|
||||
owner: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
web,
|
||||
clone,
|
||||
relays,
|
||||
euc,
|
||||
maintainers,
|
||||
upstream,
|
||||
hashtags,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the owner and identifier as a repository address.
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
RepoAddr::new(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// Returns the name, defaulting to "Untitled".
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone().unwrap_or("Untitled".into())
|
||||
}
|
||||
|
||||
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
|
||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||
if self.addr() == *base {
|
||||
return false;
|
||||
}
|
||||
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
|
||||
return true;
|
||||
}
|
||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||
}
|
||||
|
||||
/// Returns the description, defaulting to "No description".
|
||||
pub fn description(&self) -> String {
|
||||
self.description
|
||||
.clone()
|
||||
.unwrap_or("No description".to_string())
|
||||
}
|
||||
|
||||
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
|
||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||
let mut maintainers = self.maintainers.clone();
|
||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||
maintainers.push(self.owner);
|
||||
}
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// Returns deduplicated `git clone` commands for the advertised URLs.
|
||||
pub fn clone_urls(&self) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
self.clone
|
||||
.iter()
|
||||
.map(|url| format!("git clone {url}"))
|
||||
.filter(|command| seen.insert(command.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
||||
|
||||
/// Returns the test signer keys.
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a signed repo announcement event from raw tags.
|
||||
fn announcement_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Parses an event carrying every supported tag.
|
||||
#[test]
|
||||
fn parses_full_announcement() {
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-repo"],
|
||||
&["name", "My Repo"],
|
||||
&["description", "A test repository"],
|
||||
&["web", "https://example.com/repo"],
|
||||
&["clone", "https://example.com/repo.git"],
|
||||
&["relays", "wss://relay.example.com"],
|
||||
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
&["maintainers", MAINTAINER_HEX],
|
||||
&["t", "rust"],
|
||||
&["t", "nostr"],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert_eq!(announcement.owner, keys().public_key());
|
||||
assert_eq!(announcement.id, "my-repo");
|
||||
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
|
||||
assert_eq!(
|
||||
announcement.description.as_deref(),
|
||||
Some("A test repository")
|
||||
);
|
||||
assert_eq!(
|
||||
announcement.web,
|
||||
vec![Url::parse("https://example.com/repo").unwrap()]
|
||||
);
|
||||
assert_eq!(
|
||||
announcement.clone,
|
||||
vec![Url::parse("https://example.com/repo.git").unwrap()]
|
||||
);
|
||||
assert_eq!(
|
||||
announcement.relays,
|
||||
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
|
||||
);
|
||||
assert_eq!(
|
||||
announcement.euc.as_deref(),
|
||||
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
|
||||
);
|
||||
assert_eq!(
|
||||
announcement.maintainers,
|
||||
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
||||
);
|
||||
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
||||
}
|
||||
|
||||
/// Skips malformed clone, relay, and maintainer values.
|
||||
#[test]
|
||||
fn drops_malformed_values() {
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-repo"],
|
||||
&["clone", "not a url"],
|
||||
&["relays", "wss://good.example.com"],
|
||||
&["maintainers", "not-a-pubkey"],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert!(announcement.clone.is_empty());
|
||||
assert_eq!(
|
||||
announcement.relays,
|
||||
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
|
||||
);
|
||||
assert!(announcement.maintainers.is_empty());
|
||||
}
|
||||
|
||||
/// Parses the `u` tag into an upstream coordinate.
|
||||
#[test]
|
||||
fn parses_upstream_tag() {
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&[
|
||||
"u",
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
|
||||
"wss://relay.example.com",
|
||||
],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let upstream = announcement.upstream.expect("parses the u tag");
|
||||
|
||||
assert_eq!(
|
||||
upstream.addr,
|
||||
Some(RepoAddr::new(
|
||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||
"upstream"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.raw,
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.display().to_string(),
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
|
||||
);
|
||||
}
|
||||
|
||||
/// Matches a fork through its `u` tag coordinate.
|
||||
#[test]
|
||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||
let base = RepoAddr::new(
|
||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||
"upstream",
|
||||
);
|
||||
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
||||
let fork = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert!(fork.is_fork_of(&base, None));
|
||||
}
|
||||
|
||||
/// Matches forks sharing the base's earliest unique commit.
|
||||
#[test]
|
||||
fn is_fork_of_matches_a_shared_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||
let base = Announcement::from_event(&base_event).expect("parses");
|
||||
let base_addr = base.addr();
|
||||
|
||||
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
||||
let fork = Announcement::from_event(&fork_event).expect("parses");
|
||||
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
|
||||
let other_event = announcement_event(&[
|
||||
&["d", "other"],
|
||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
]);
|
||||
let other = Announcement::from_event(&other_event).expect("parses");
|
||||
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
|
||||
assert!(!fork.is_fork_of(&base_addr, None));
|
||||
}
|
||||
|
||||
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
|
||||
#[test]
|
||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||
let base = RepoAddr::new(
|
||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||
"upstream",
|
||||
);
|
||||
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", &base.to_string()],
|
||||
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
]);
|
||||
let fork = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||
}
|
||||
|
||||
/// Includes the owner among maintainers for primary repositories.
|
||||
#[test]
|
||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
assert_eq!(maintainers.len(), 2);
|
||||
assert!(maintainers.contains(&announcement.owner));
|
||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||
}
|
||||
|
||||
/// Excludes the owner from maintainers for subordinate forks.
|
||||
#[test]
|
||||
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
||||
&["maintainers", MAINTAINER_HEX],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
assert!(!maintainers.contains(&announcement.owner));
|
||||
assert_eq!(
|
||||
maintainers,
|
||||
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
||||
);
|
||||
}
|
||||
|
||||
const OWNER_KEYS: [&str; 3] = [
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
];
|
||||
|
||||
/// Builds an announcement event signed by the given owner key.
|
||||
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds one announcement owned by the owner key at the given index.
|
||||
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
|
||||
vec![
|
||||
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
|
||||
.expect("parses"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Places the user's own forks before everyone else's.
|
||||
#[test]
|
||||
fn fork_candidates_orders_own_forks_first() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let clone = "https://grasp.example/npub1x/my-fork.git";
|
||||
|
||||
let base_addr = RepoAddr::new(
|
||||
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
||||
"upstream",
|
||||
);
|
||||
let all = vec![
|
||||
owned_announcements(
|
||||
2,
|
||||
&[
|
||||
&["d", "other-project"],
|
||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
],
|
||||
)
|
||||
.pop()
|
||||
.unwrap(),
|
||||
owned_announcements(
|
||||
1,
|
||||
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
|
||||
)
|
||||
.pop()
|
||||
.unwrap(),
|
||||
owned_announcements(
|
||||
2,
|
||||
&[
|
||||
&["d", "their-fork"],
|
||||
&["u", &base_addr.to_string()],
|
||||
&["clone", clone],
|
||||
],
|
||||
)
|
||||
.pop()
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
||||
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user));
|
||||
|
||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||
}
|
||||
|
||||
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
|
||||
#[test]
|
||||
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
|
||||
let base_addr = RepoAddr::new(base_owner, "upstream");
|
||||
|
||||
let mut all = vec![
|
||||
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
||||
.pop()
|
||||
.unwrap(),
|
||||
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
|
||||
.pop()
|
||||
.unwrap(),
|
||||
owned_announcements(
|
||||
2,
|
||||
&[
|
||||
&["d", "other"],
|
||||
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
],
|
||||
)
|
||||
.pop()
|
||||
.unwrap(),
|
||||
owned_announcements(
|
||||
2,
|
||||
&[
|
||||
&["d", "mirror"],
|
||||
&["r", euc, "euc"],
|
||||
&["clone", "https://grasp.example/x/mirror.git"],
|
||||
],
|
||||
)
|
||||
.pop()
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner));
|
||||
assert_eq!(forks.len(), 1);
|
||||
assert_eq!(forks[0].id, "mirror");
|
||||
|
||||
all.push(
|
||||
owned_announcements(
|
||||
2,
|
||||
&[
|
||||
&["d", "u-fork"],
|
||||
&["u", &base_addr.to_string()],
|
||||
&["clone", "https://grasp.example/x/u-fork.git"],
|
||||
],
|
||||
)
|
||||
.pop()
|
||||
.unwrap(),
|
||||
);
|
||||
let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner));
|
||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["u-fork"]);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Target of a `nostr://` clone URL, as defined by NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CloneTarget {
|
||||
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
/// `npub1...` or a NIP-05 identifier.
|
||||
user: String,
|
||||
relay_hint: Option<RelayUrl>,
|
||||
/// `d` tag identifier of the repository.
|
||||
identifier: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a `nostr://` clone URL. Returns `None` for other URL schemes.
|
||||
pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
let mut parts = rest.split('/');
|
||||
|
||||
let first = parts.next()?;
|
||||
let second = parts.next()?;
|
||||
let third = parts.next();
|
||||
|
||||
if first.starts_with("naddr1") {
|
||||
let coordinate = Nip19Coordinate::from_bech32(first).ok()?;
|
||||
return Some(CloneTarget::Addr(coordinate.coordinate));
|
||||
}
|
||||
|
||||
let (relay_hint, identifier) = match third {
|
||||
Some(id) => (
|
||||
RelayUrl::parse(&percent_decode(second)).ok(),
|
||||
percent_decode(id),
|
||||
),
|
||||
None => (None, percent_decode(second)),
|
||||
};
|
||||
|
||||
Some(CloneTarget::UserRepo {
|
||||
user: first.to_owned(),
|
||||
relay_hint,
|
||||
identifier,
|
||||
})
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = &input[i + 1..i + 3];
|
||||
if let Ok(v) = u8::from_str_radix(hex, 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_user_repo_without_relay() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(),
|
||||
relay_hint: None,
|
||||
identifier: "ngit".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_repo_with_relay_hint() {
|
||||
let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("relay.ngit.dev").ok(),
|
||||
identifier: "ngit".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_parts() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
|
||||
identifier: "my-local-only-repo".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,18 @@ use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||
/// built from the kind-5 and kind-62 events in the local database.
|
||||
///
|
||||
/// Deleted events are hidden before they reach the UI.
|
||||
///
|
||||
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||
pub struct Deletions {
|
||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||
ids: HashSet<(EventId, PublicKey)>,
|
||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||
///
|
||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
||||
vanished: Vec<(PublicKey, Timestamp)>,
|
||||
}
|
||||
|
||||
impl Deletions {
|
||||
/// Build the deletion index from raw kind-5 and kind-62 events.
|
||||
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
||||
/// Builds a [`Deletions`] instance from a collection of events.
|
||||
pub fn from_events<E>(events: E) -> Self
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
{
|
||||
let mut ids = HashSet::new();
|
||||
let mut coords = Vec::new();
|
||||
let mut vanished = Vec::new();
|
||||
@@ -36,8 +28,6 @@ impl Deletions {
|
||||
.map(|c| (c, event.pubkey, event.created_at)),
|
||||
);
|
||||
} else if event.kind == Kind::RequestToVanish {
|
||||
// Client-side we can't verify which relay the request targeted.
|
||||
// Any vanish request is then honored for the author's events.
|
||||
vanished.push((event.pubkey, event.created_at));
|
||||
}
|
||||
}
|
||||
@@ -49,10 +39,7 @@ impl Deletions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the event is covered by a valid deletion or vanish request.
|
||||
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
||||
///
|
||||
/// Addressable events are deleted up to the request's `created_at`.
|
||||
/// Returns whether the given event is deleted.
|
||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||
if self
|
||||
.vanished
|
||||
|
||||
+126
-222
@@ -2,9 +2,6 @@ use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||
|
||||
/// Kinds that make up the activity of a repository.
|
||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
Kind::Comment,
|
||||
Kind::GitPatch,
|
||||
@@ -17,20 +14,17 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||
const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequestUpdate,
|
||||
COVER_NOTE_KIND,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Git root kinds that make a comment or cover note count as git activity.
|
||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPatch,
|
||||
@@ -38,203 +32,148 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitRepoAnnouncement,
|
||||
];
|
||||
|
||||
/// Value of the first tag named `name` on `event`.
|
||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||
kind == Kind::GitRepoAnnouncement
|
||||
|| kind == Kind::RepoState
|
||||
|| kind == Kind::EventDeletion
|
||||
|| kind == Kind::RequestToVanish
|
||||
|| ACTIVITY_KINDS.contains(&kind)
|
||||
}
|
||||
|
||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|value| value.parse::<Kind>().ok())
|
||||
}
|
||||
|
||||
/// Kind named by the first tag `name` on `event`.
|
||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||
tag_value(event, name)?.parse::<Kind>().ok()
|
||||
}
|
||||
pub struct Filters;
|
||||
|
||||
/// Latest announcement event for a repository.
|
||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(addr.public_key)
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
impl Filters {
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
/// Latest state event for a repository, carrying refs and HEAD.
|
||||
pub fn state(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
.author(addr.public_key)
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
|
||||
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
||||
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||
/// The `a` tag is optional on status events per NIP-34.
|
||||
/// Statuses published without it are not matched here.
|
||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||
}
|
||||
|
||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||
///
|
||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// Cover notes and NIP-32 label events referencing any of the given root events.
|
||||
/// These are kinds 1624 and 1985, matched via the `#e` tag.
|
||||
///
|
||||
/// Because they carry no repository `a` tag, they are fetched by root like comments.
|
||||
///
|
||||
/// Batched, like [`statuses_for`].
|
||||
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// A user's grasp list, kind `10317`.
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||
/// The roots are issues, patches and PRs.
|
||||
///
|
||||
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
if roots.is_empty() {
|
||||
return Vec::new();
|
||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.events(roots)
|
||||
}
|
||||
vec![
|
||||
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-51: code (people who produce NIP-34 events) follow list
|
||||
pub fn git_authors(public_key: PublicKey) -> Filter {
|
||||
Filter::new().kind(Kind::Custom(10017)).author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-51: NIP-34 followed repositories
|
||||
pub fn git_repos(public_key: PublicKey) -> Filter {
|
||||
Filter::new().kind(Kind::Custom(10018)).author(public_key)
|
||||
}
|
||||
|
||||
/// Replaceable events, so the latest of each kind is all we need.
|
||||
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::grasp_list(public_key).limit(1),
|
||||
Self::git_authors(public_key).limit(1),
|
||||
Self::git_repos(public_key).limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::MuteList)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::BlossomServerList)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
|
||||
if roots.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
||||
]
|
||||
}
|
||||
|
||||
fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||
}
|
||||
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
pub fn all_states() -> Filter {
|
||||
Filter::new().kind(Kind::RepoState)
|
||||
}
|
||||
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
||||
]
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.since(Self::deletions_since())
|
||||
}
|
||||
}
|
||||
|
||||
/// NIP-22 comments on our issues, patches and pull requests.
|
||||
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
/// Activity directed at us: comments on our roots, and git events tagging us
|
||||
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
/// Git activity authored by `me`, for "Continue where you left off".
|
||||
///
|
||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||
/// through [`is_git_activity`] before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(
|
||||
ACTIVITY_KINDS
|
||||
.into_iter()
|
||||
.chain(std::iter::once(COVER_NOTE_KIND)),
|
||||
)
|
||||
.author(me)
|
||||
}
|
||||
|
||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||
fn is_git_comment(event: &Event) -> bool {
|
||||
pub(crate) fn is_git_comment(event: &Event) -> bool {
|
||||
event.kind == Kind::Comment
|
||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
||||
fn is_git_cover_note(event: &Event) -> bool {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a status event references a git root, checked via its `k` tag.
|
||||
fn is_git_status(event: &Event) -> bool {
|
||||
pub(crate) fn is_git_status(event: &Event) -> bool {
|
||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether `event` is git activity worth showing in the activity list.
|
||||
pub fn is_git_activity(event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => is_git_comment(event),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => is_git_status(event),
|
||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
||||
}
|
||||
}
|
||||
|
||||
/// All repository announcements, for global discovery.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// How far back deletion requests are fetched and stored.
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||
/// Quantized to whole days so identical filters hash the same.
|
||||
///
|
||||
/// This lets the backend's sync dedup match identical filters.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||
///
|
||||
/// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.since(deletions_since())
|
||||
}
|
||||
|
||||
/// Deletion events relevant to a single repository.
|
||||
///
|
||||
/// Requests authored by the repository owner.
|
||||
///
|
||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.author(addr.public_key),
|
||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::GitEvent;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
@@ -253,13 +192,6 @@ mod tests {
|
||||
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_git_kinds_are_activity() {
|
||||
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
|
||||
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_activity_depends_on_the_uppercase_k_tag() {
|
||||
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
|
||||
@@ -270,55 +202,27 @@ mod tests {
|
||||
);
|
||||
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
||||
|
||||
assert!(is_git_activity(&on_git));
|
||||
assert!(is_git_activity(&on_repo));
|
||||
assert!(!is_git_activity(&on_note));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::Comment,
|
||||
Vec::new()
|
||||
)));
|
||||
assert!(on_git.is_git_activity());
|
||||
assert!(on_repo.is_git_activity());
|
||||
assert!(!on_note.is_git_activity());
|
||||
assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
||||
fn status_activity_depends_on_the_lowercase_k_tag() {
|
||||
let status = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||
);
|
||||
let cover = signed(
|
||||
&keys(1),
|
||||
COVER_NOTE_KIND,
|
||||
vec![kind_tag("k", Kind::GitPatch)],
|
||||
);
|
||||
let unrelated = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::Metadata)],
|
||||
);
|
||||
|
||||
assert!(is_git_activity(&status));
|
||||
assert!(is_git_activity(&cover));
|
||||
assert!(!is_git_activity(&unrelated));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
Vec::new()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_kinds_are_not_activity() {
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::TextNote,
|
||||
Vec::new()
|
||||
)));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitPullRequestUpdate,
|
||||
Vec::new(),
|
||||
)));
|
||||
assert!(status.is_git_activity());
|
||||
assert!(!unrelated.is_git_activity());
|
||||
assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
pub trait GitEvent {
|
||||
/// Returns the NIP-34 subject or the first non-empty content line.
|
||||
fn activity_subject(&self) -> String;
|
||||
|
||||
/// Returns the current commit id.
|
||||
fn current_commit(&self) -> Option<String>;
|
||||
|
||||
/// Returns the merge base commit id.
|
||||
fn merge_base(&self) -> Option<String>;
|
||||
|
||||
/// Returns the advertised clone URLs.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||
|
||||
/// Returns the branch name.
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
/// Returns whether the event is git activity.
|
||||
fn is_git_activity(&self) -> bool;
|
||||
|
||||
/// Returns whether an `e` or `E` tag points at the root event.
|
||||
fn references_root(&self, root: &EventId) -> bool;
|
||||
}
|
||||
|
||||
impl GitEvent for Event {
|
||||
/// Reads the subject tag, then the first content line, then "Untitled".
|
||||
fn activity_subject(&self) -> String {
|
||||
let subject = self
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
self.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Reads the `commit` tag.
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `merge-base` tag.
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `clone` tag.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `branch-name` tag.
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Classifies git issue, patch, pull request, comment, and status kinds.
|
||||
fn is_git_activity(&self) -> bool {
|
||||
match self.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => crate::filters::is_git_comment(self),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares `e` and `E` tag contents against the root id.
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
self.tags
|
||||
.iter()
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||
/// Delegates to the referenced event.
|
||||
fn activity_subject(&self) -> String {
|
||||
(*self).activity_subject()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
(*self).current_commit()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
(*self).merge_base()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
(*self).clone_urls()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
(*self).branch_name()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn is_git_activity(&self) -> bool {
|
||||
(*self).is_git_activity()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
(*self).references_root(root)
|
||||
}
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
||||
|
||||
/// Window before `now` that an advanced cutoff retreats to.
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
|
||||
/// Window before `now` that a mark-all cutoff retreats to.
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
/// A thread of notification and own-activity events sharing one root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
/// The root issue, patch or pull request the events belong to.
|
||||
pub root: EventId,
|
||||
/// The root event itself, when it is known locally.
|
||||
pub root_event: Option<Event>,
|
||||
/// Kind of the root event, when it is known locally.
|
||||
pub root_kind: Option<Kind>,
|
||||
/// Repository the root belongs to, from the root's `a` tag.
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
/// Whether every notification event in the thread is archived.
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Title of the thread, read from its root issue/patch/PR when known.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(activity_subject)
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Kind shown for the thread.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_kind.or_else(|| {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
})
|
||||
}
|
||||
|
||||
/// Timestamp of the newest event in the thread.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(self.own_events.first())
|
||||
.chain(self.events.first())
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Up to `limit` events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
if let Some(root) = &self.root_event {
|
||||
seen.insert(root.id);
|
||||
events.push(root.clone());
|
||||
}
|
||||
|
||||
let mut rest: Vec<Event> = self
|
||||
.own_events
|
||||
.iter()
|
||||
.chain(self.events.iter())
|
||||
.filter(|event| seen.insert(event.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
rest.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
rest.truncate(limit.saturating_sub(events.len()));
|
||||
events.extend(rest);
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
events
|
||||
}
|
||||
|
||||
/// Whether the thread has an unread event still visible in the inbox.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from `state`.
|
||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||
self.unread_ids = self
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| !state.is_read(event))
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
|
||||
// A thread without notification events is never archived.
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
|
||||
/// Root issue, patch or pull request of a notification event.
|
||||
///
|
||||
/// Returns `None` when the event is not git-related, or when its root is a
|
||||
/// coordinate rather than an event.
|
||||
///
|
||||
/// - issue (1621) / PR (1618): itself
|
||||
/// - patch (1617): its `e` parent patch, else itself
|
||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||
/// - PR update (1619): uppercase `E`
|
||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
if event.kind == COVER_NOTE_KIND {
|
||||
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
||||
}
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match first_e_id(event) {
|
||||
Some(parent) => resolve_thread_root(parent, lookup),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => {
|
||||
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => {
|
||||
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Group notification events and the user's own events into one item per thread.
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = notification_root(&event, lookup) else {
|
||||
continue;
|
||||
};
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = notification_root(&event, lookup).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next()),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
item.apply_state(state);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
b.latest_activity()
|
||||
.cmp(&a.latest_activity())
|
||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Sort thread events newest first, ties broken by id.
|
||||
fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Read and archive state of the inbox, a high-water-mark model.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)]
|
||||
pub read_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)]
|
||||
pub archived_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
/// Whether `event` is at or before the read cutoff, or marked read.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Whether `event` is at or before the archived cutoff, or marked archived.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Mark one event read. Events at or before the cutoff are already read.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark one event archived. Events at or before the cutoff are already archived.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = now - MARK_ALL_WINDOW;
|
||||
self.read_before = cutoff;
|
||||
self.read_ids = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Advance the read cutoff to the newest point that keeps unread events
|
||||
/// unread, then prune the id set.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||
self.read_before = cutoff;
|
||||
prune_ids(&mut self.read_ids, all, cutoff);
|
||||
}
|
||||
|
||||
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||
self.is_archived(event)
|
||||
});
|
||||
self.archived_before = cutoff;
|
||||
prune_ids(&mut self.archived_ids, all, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
|
||||
fn advance_cutoff<M>(
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
now: Timestamp,
|
||||
current: Timestamp,
|
||||
is_marked: M,
|
||||
) -> Timestamp
|
||||
where
|
||||
M: Fn(&Event) -> bool,
|
||||
{
|
||||
let fallback = now - ADVANCE_WINDOW;
|
||||
|
||||
let oldest = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && !is_marked(event))
|
||||
.map(|event| event.created_at)
|
||||
.min();
|
||||
|
||||
let candidate = match oldest {
|
||||
Some(at) if at < fallback => at - 1,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
candidate.max(current)
|
||||
}
|
||||
|
||||
/// Drop ids whose event is unknown or now covered by the cutoff.
|
||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||
let created_at: HashMap<EventId, Timestamp> = all
|
||||
.iter()
|
||||
.map(|event| (event.id, event.created_at))
|
||||
.collect();
|
||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||
}
|
||||
|
||||
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = lookup(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
||||
fn parent_id(event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| e_tag_with_marker(tag, marker))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != "e" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slice = tag.as_slice();
|
||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||
|
||||
if is_mention {
|
||||
return None;
|
||||
}
|
||||
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
||||
fn nip10_root_id(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| first_e_id(event))
|
||||
}
|
||||
|
||||
/// First `e` tag id, in document order.
|
||||
fn first_e_id(event: &Event) -> Option<EventId> {
|
||||
first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
/// First uppercase `E` tag id, in document order.
|
||||
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
|
||||
first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != name {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Event id from a four-element `e` tag carrying `marker`.
|
||||
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
|
||||
let slice = tag.as_slice();
|
||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
||||
.expect("valid e tag")
|
||||
}
|
||||
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
|
||||
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
|
||||
}
|
||||
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
fn issue(author: &Keys, at: u64) -> Event {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_and_pull_request_are_their_own_root() {
|
||||
let events = [
|
||||
issue(&keys(1), 100),
|
||||
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
|
||||
];
|
||||
let lookup = lookup(&events);
|
||||
for event in &events {
|
||||
assert_eq!(notification_root(event, &lookup), Some(event.id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_resolves_to_its_uppercase_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), comment.clone()];
|
||||
assert_eq!(
|
||||
notification_root(&comment, &lookup(&events)),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_without_root_pointer_has_no_root() {
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![e_tag(&issue(&keys(1), 100))],
|
||||
200,
|
||||
);
|
||||
assert_eq!(notification_root(&comment, &lookup(&[])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||
let events = [root_patch.clone(), child_patch.clone()];
|
||||
assert_eq!(
|
||||
notification_root(&child_patch, &lookup(&events)),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_resolves_via_the_root_marker() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let status = signed(
|
||||
&keys(2),
|
||||
Kind::GitStatusClosed,
|
||||
vec![marked_e_tag(&issue, "root")],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), status.clone()];
|
||||
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_update_resolves_via_uppercase_e() {
|
||||
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
|
||||
let update = signed(
|
||||
&keys(2),
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![uppercase_e_tag(&pr)],
|
||||
200,
|
||||
);
|
||||
let events = [pr.clone(), update.clone()];
|
||||
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||
let events = [issue.clone(), reply, nested.clone()];
|
||||
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_excludes_self_and_sorts_groups_newest_first() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
let other_issue = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
|
||||
200,
|
||||
);
|
||||
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
|
||||
|
||||
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
|
||||
let items = group(
|
||||
events,
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
// The issue itself plus the comment; the self-authored comment is out.
|
||||
assert_eq!(items[0].events.len(), 2);
|
||||
assert_eq!(items[1].root, other_issue.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reports_unread_oldest_first_and_archived() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
|
||||
let events = [issue.clone(), older.clone(), newer.clone()];
|
||||
let items = group(
|
||||
events,
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
);
|
||||
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
|
||||
assert!(!items[0].archived);
|
||||
assert!(items[0].is_unread());
|
||||
|
||||
let state = InboxReadState {
|
||||
archived_before: Timestamp::from_secs(1000),
|
||||
..Default::default()
|
||||
};
|
||||
let items = group(
|
||||
[issue.clone(), older, newer],
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&state,
|
||||
&lookup(&[]),
|
||||
);
|
||||
assert!(items[0].archived);
|
||||
assert!(!items[0].unread_ids.is_empty());
|
||||
assert!(!items[0].is_unread());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reads_root_kind_and_address_from_the_root_event() {
|
||||
let me = keys(1);
|
||||
let owner_keys = keys(2);
|
||||
let owner = owner_keys.public_key();
|
||||
let issue = signed(
|
||||
&owner_keys,
|
||||
Kind::GitIssue,
|
||||
vec![a_tag(&owner, "my-repo")],
|
||||
100,
|
||||
);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
|
||||
let events = [issue.clone(), comment];
|
||||
let items = group(
|
||||
events.clone(),
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&events),
|
||||
);
|
||||
|
||||
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].address, issue.tags.coordinates().next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
// The own events are kept apart from the notifications, newest first.
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_all_read_marks_known_recent_events() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let recent = issue(&keys(2), now.as_secs() - 1000);
|
||||
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
|
||||
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
|
||||
let mine = issue(&keys(1), now.as_secs() - 100);
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_all_read(
|
||||
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
|
||||
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
|
||||
assert!(state.is_read(&recent));
|
||||
assert!(state.is_read(&ancient));
|
||||
assert!(!state.is_read(&mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_never_moves_the_cutoff_backwards() {
|
||||
let me = keys(1);
|
||||
let unread = issue(&keys(2), 1_000);
|
||||
let all = [unread];
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(999_999_999),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(&all, me.public_key(), now);
|
||||
|
||||
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let five_days = 5 * 24 * 60 * 60;
|
||||
let old_unread = issue(&keys(2), now.as_secs() - five_days);
|
||||
// Read ids that fall before and after the new cutoff.
|
||||
let stale = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - five_days - 1000,
|
||||
);
|
||||
let fresh = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - 100_000,
|
||||
);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_ids: HashSet::from([stale.id, fresh.id]),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(
|
||||
&[old_unread.clone(), stale.clone(), fresh.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, old_unread.created_at - 1);
|
||||
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let event = issue(&keys(2), now.as_secs() - 1000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
archived_before: now,
|
||||
..Default::default()
|
||||
};
|
||||
state.mark_archived(&event);
|
||||
assert!(state.archived_ids.is_empty());
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_archived(&event);
|
||||
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_state_recomputes_unread_and_archived() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let first = issue(&keys(2), now.as_secs() - 2000);
|
||||
let second = issue(&keys(2), now.as_secs() - 1000);
|
||||
let mut item = InboxItem {
|
||||
root: first.id,
|
||||
root_event: None,
|
||||
root_kind: None,
|
||||
address: None,
|
||||
events: vec![second.clone(), first.clone()],
|
||||
own_events: Vec::new(),
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
|
||||
let state = InboxReadState {
|
||||
read_before: first.created_at,
|
||||
..Default::default()
|
||||
};
|
||||
item.apply_state(&state);
|
||||
|
||||
assert_eq!(item.unread_ids, vec![second.id]);
|
||||
assert!(!item.archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_preserves_state() {
|
||||
let first = issue(&keys(1), 100);
|
||||
let second = issue(&keys(2), 200);
|
||||
let state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(150),
|
||||
read_ids: HashSet::from([second.id]),
|
||||
archived_before: Timestamp::from_secs(50),
|
||||
archived_ids: HashSet::from([first.id]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&state).expect("serialized");
|
||||
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
|
||||
|
||||
assert_eq!(parsed, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use utils::sort_newest_first;
|
||||
|
||||
use super::{InboxItem, InboxReadState, ThreadResolver};
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Groups events into inbox threads, merging the user's own events.
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let resolver = ThreadResolver::new(lookup);
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = resolver.notification_root(&event) else {
|
||||
continue;
|
||||
};
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = resolver.notification_root(&event).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next())
|
||||
.map(RepoAddr::from),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
item.apply_state(state);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
b.latest_activity()
|
||||
.cmp(&a.latest_activity())
|
||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Derives deterministic signer keys from a seed byte.
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds an uppercase `E` tag for the event.
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
/// Builds an event lookup closure over a slice.
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Builds a git issue event with a subject tag.
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
/// Merges the user's own events into the matching thread.
|
||||
#[test]
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use super::InboxReadState;
|
||||
use crate::{GitEvent, RepoAddr};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
pub root_event: Option<Event>,
|
||||
pub address: Option<RepoAddr>,
|
||||
pub events: Vec<Event>,
|
||||
pub own_events: Vec<Event>,
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Returns the thread title from the root, own events, or events.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.activity_subject())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Returns the kind of the thread's primary event.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
/// Returns the newest timestamp among the root, own, and other events.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(self.own_events.first())
|
||||
.chain(self.events.first())
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Builds a chronological timeline capped at `limit` events, root first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
if let Some(root) = &self.root_event {
|
||||
seen.insert(root.id);
|
||||
events.push(root.clone());
|
||||
}
|
||||
|
||||
let mut rest: Vec<Event> = self
|
||||
.own_events
|
||||
.iter()
|
||||
.chain(self.events.iter())
|
||||
.filter(|event| seen.insert(event.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
rest.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
rest.truncate(limit.saturating_sub(events.len()));
|
||||
events.extend(rest);
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
events
|
||||
}
|
||||
|
||||
/// Returns whether the thread has unread events and is not archived.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recomputes unread ids and the archived flag from the read state.
|
||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||
self.unread_ids = self
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| !state.is_read(event))
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod group;
|
||||
mod item;
|
||||
mod read_state;
|
||||
mod resolver;
|
||||
|
||||
pub use group::group;
|
||||
pub use item::InboxItem;
|
||||
pub use read_state::InboxReadState;
|
||||
pub use resolver::ThreadResolver;
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)]
|
||||
pub read_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)]
|
||||
pub archived_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
/// Returns whether the event falls under the read high-water mark or id set.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Returns whether the event falls under the archived high-water mark or id set.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Records the event as read when it is past the current cutoff.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the event as archived when it is past the current cutoff.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks all events from other authors within the window as read.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = now - MARK_ALL_WINDOW;
|
||||
self.read_before = cutoff;
|
||||
self.read_ids = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Advances the read cutoff as far as possible, then prunes the id set.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||
self.read_before = cutoff;
|
||||
prune_ids(&mut self.read_ids, all, cutoff);
|
||||
}
|
||||
|
||||
/// Advances the archived cutoff as far as possible, then prunes the id set.
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||
self.is_archived(event)
|
||||
});
|
||||
self.archived_before = cutoff;
|
||||
prune_ids(&mut self.archived_ids, all, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the newest cutoff that leaves unmarked events uncovered.
|
||||
fn advance_cutoff<M>(
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
now: Timestamp,
|
||||
current: Timestamp,
|
||||
is_marked: M,
|
||||
) -> Timestamp
|
||||
where
|
||||
M: Fn(&Event) -> bool,
|
||||
{
|
||||
let fallback = now - ADVANCE_WINDOW;
|
||||
|
||||
let oldest = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && !is_marked(event))
|
||||
.map(|event| event.created_at)
|
||||
.min();
|
||||
|
||||
let candidate = match oldest {
|
||||
Some(at) if at < fallback => at - 1,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
candidate.max(current)
|
||||
}
|
||||
|
||||
/// Drops ids whose events predate the cutoff.
|
||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||
let created_at: HashMap<EventId, Timestamp> = all
|
||||
.iter()
|
||||
.map(|event| (event.id, event.created_at))
|
||||
.collect();
|
||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||
lookup: &'a L,
|
||||
}
|
||||
|
||||
impl<'a, L> ThreadResolver<'a, L>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||
{
|
||||
/// Creates a resolver over the given event lookup.
|
||||
pub fn new(lookup: &'a L) -> Self {
|
||||
Self { lookup }
|
||||
}
|
||||
|
||||
/// Maps a git event kind to the thread root it notifies about.
|
||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||
Some(parent) => self.resolve_thread_root(parent),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => self
|
||||
.first_uppercase_e_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => self
|
||||
.nip10_root_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows parent pointers until a git issue or pull request root is reached.
|
||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = (self.lookup)(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match self.parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors gitworkshop's `getParentId` parent resolution.
|
||||
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, marker))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != "e" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slice = tag.as_slice();
|
||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||
|
||||
if is_mention {
|
||||
return None;
|
||||
}
|
||||
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
self.first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
/// Reads the NIP-10 `root` marker, falling back to the first `e` tag.
|
||||
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| self.first_e_id(event))
|
||||
}
|
||||
|
||||
/// Reads the first lowercase `e` tag id.
|
||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
/// Reads the first uppercase `E` tag id.
|
||||
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
/// Reads the first event id from tags of the given kind.
|
||||
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != name {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a four-value `e` tag carrying the given marker.
|
||||
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
|
||||
let slice = tag.as_slice();
|
||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Derives deterministic signer keys from a seed byte.
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds a plain lowercase `e` tag for the event.
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
/// Builds a four-value `e` tag with the given marker.
|
||||
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
||||
.expect("valid e tag")
|
||||
}
|
||||
|
||||
/// Builds an uppercase `E` tag for the event.
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
/// Builds an event lookup closure over a slice.
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Builds a git issue event.
|
||||
fn issue(author: &Keys, at: u64) -> Event {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
/// Resolves a comment to its uppercase `E` root.
|
||||
#[test]
|
||||
fn comment_resolves_to_its_uppercase_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), comment.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a child patch to its root patch.
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||
let events = [root_patch.clone(), child_patch.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a status through its `root` marker tag.
|
||||
#[test]
|
||||
fn status_resolves_via_the_root_marker() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let status = signed(
|
||||
&keys(2),
|
||||
Kind::GitStatusClosed,
|
||||
vec![marked_e_tag(&issue, "root")],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), status.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Follows a nested comment chain to the issue root.
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||
let events = [issue.clone(), reply, nested.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,19 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod announcement;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod git_event;
|
||||
pub mod inbox;
|
||||
pub mod model;
|
||||
pub mod state;
|
||||
pub mod pull_request;
|
||||
pub mod status;
|
||||
pub mod upstream;
|
||||
|
||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use addr::RepoAddr;
|
||||
pub use announcement::Announcement;
|
||||
pub use deletions::Deletions;
|
||||
pub use filters::{
|
||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||
};
|
||||
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
|
||||
pub use model::{
|
||||
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
||||
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
||||
};
|
||||
pub use state::{build_state, parse_state};
|
||||
pub use status::{RepoStatus, references_root, resolve_status};
|
||||
pub use filters::Filters;
|
||||
pub use git_event::GitEvent;
|
||||
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
||||
pub use pull_request::PullRequest;
|
||||
pub use status::RepoStatus;
|
||||
pub use upstream::Upstream;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::git_event::GitEvent;
|
||||
|
||||
pub struct PullRequest<'a>(pub &'a Event);
|
||||
|
||||
impl<'a> PullRequest<'a> {
|
||||
/// Wraps a pull request root event.
|
||||
pub fn new(event: &'a Event) -> Self {
|
||||
Self(event)
|
||||
}
|
||||
|
||||
/// Returns the ordered patch series, found via the root reference or the tip commit.
|
||||
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return Self::forward_series(root, &patches);
|
||||
}
|
||||
|
||||
let Some(tip) = pr.current_commit() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
};
|
||||
let Some(prev) = patches
|
||||
.iter()
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
|
||||
/// Joins the patch series with newlines, falling back to the event content.
|
||||
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = self.patches(patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Returns the root author's newest update, since only the author may move the tip.
|
||||
pub fn latest_update(
|
||||
events: impl Iterator<Item = &'a Event>,
|
||||
root: &Event,
|
||||
) -> Option<&'a Event> {
|
||||
let root_hex = root.id.to_hex();
|
||||
events
|
||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
||||
.filter(|e| e.pubkey == root.pubkey)
|
||||
.filter(|e| {
|
||||
e.tags
|
||||
.iter()
|
||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||
})
|
||||
.max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// Walks reply links outward from the root patch, taking the newest child first.
|
||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
/// Checks the commit against the patch's `c` and `r` tags.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Returns the test signer keys.
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a signed pull request event.
|
||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(Kind::GitPullRequest, content)
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds a signed patch event with a fixed timestamp.
|
||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(Kind::GitPatch, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Joins the full patch series into one diff.
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patch([&root, &second]),
|
||||
"patch-one\npatch-two"
|
||||
);
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patches([&root, &second]),
|
||||
vec![&root, &second]
|
||||
);
|
||||
}
|
||||
|
||||
/// Orders patches by walking the reply chain.
|
||||
#[test]
|
||||
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two", "patch-three"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds the patch series through the declared tip commit.
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
"patch-two",
|
||||
vec![
|
||||
Tag::event(root.id),
|
||||
Tag::parse(["r", tip]).expect("valid tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let pr = pr_event(
|
||||
"description",
|
||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||
);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two"]
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds the pull request root used by the update tests.
|
||||
fn pr_root() -> Event {
|
||||
signed_at(
|
||||
Kind::GitPullRequest,
|
||||
vec![
|
||||
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
|
||||
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
|
||||
],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
/// Picks the newest update targeting the root.
|
||||
#[test]
|
||||
fn latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
|
||||
let revision = |created_at: u64| {
|
||||
signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
let unrelated = signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
999,
|
||||
);
|
||||
|
||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||
|
||||
assert_eq!(latest.created_at.as_secs(), 300);
|
||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||
}
|
||||
|
||||
/// Ignores updates from authors other than the root owner.
|
||||
#[test]
|
||||
fn latest_update_ignores_other_authors() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
let other = Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.expect("valid secret key"),
|
||||
);
|
||||
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
|
||||
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
|
||||
.custom_created_at(Timestamp::from(999))
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||
/// it is published as `ref: refs/heads/<branch>`.
|
||||
///
|
||||
/// The `d` tag matches the repository id.
|
||||
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
for (name, commit) in refs {
|
||||
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||
}
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
}
|
||||
|
||||
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||
///
|
||||
/// `refs` are `(refname, commit-id)` pairs.
|
||||
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
||||
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(refs, head)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
||||
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a signed kind `30618` event from raw tag values.
|
||||
fn state_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
|
||||
EventBuilder::new(Kind::RepoState, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_heads_and_tags() {
|
||||
let event = state_event(&[
|
||||
&["HEAD", "ref: refs/heads/main"],
|
||||
&["refs/heads/main", COMMIT_A],
|
||||
&["refs/heads/dev", COMMIT_B],
|
||||
&["refs/tags/v1.0", COMMIT_A],
|
||||
]);
|
||||
|
||||
let (refs, head) = parse_state(&event);
|
||||
|
||||
assert_eq!(head.as_deref(), Some("main"));
|
||||
assert_eq!(
|
||||
refs,
|
||||
vec![
|
||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_without_prefix_is_ignored() {
|
||||
let event = state_event(&[&["HEAD", "main"]]);
|
||||
|
||||
let (refs, head) = parse_state(&event);
|
||||
|
||||
assert!(refs.is_empty());
|
||||
assert!(head.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_state_tags() {
|
||||
let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]);
|
||||
|
||||
let (refs, head) = parse_state(&event);
|
||||
|
||||
assert!(refs.is_empty());
|
||||
assert!(head.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_state_round_trips_through_parse() {
|
||||
let refs = [
|
||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||
];
|
||||
|
||||
let event = build_state("my-repo", &refs, Some("main"))
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
|
||||
assert_eq!(event.kind, Kind::RepoState);
|
||||
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||
|
||||
let (parsed_refs, head) = parse_state(&event);
|
||||
assert_eq!(parsed_refs, refs);
|
||||
assert_eq!(head.as_deref(), Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_state_omits_head_when_detached() {
|
||||
let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())];
|
||||
|
||||
let event = build_state("my-repo", &refs, None)
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
|
||||
let (parsed_refs, head) = parse_state(&event);
|
||||
assert_eq!(parsed_refs, refs);
|
||||
assert!(head.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RepoStatus {
|
||||
Open,
|
||||
@@ -28,42 +27,29 @@ impl RepoStatus {
|
||||
Self::Draft => Kind::GitStatusDraft,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
||||
///
|
||||
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
|
||||
/// Resolve the status of a root event per NIP-34.
|
||||
///
|
||||
/// Defaults to [`RepoStatus::Open`].
|
||||
pub fn resolve_status<'a, I>(
|
||||
status_events: I,
|
||||
root_author: &PublicKey,
|
||||
maintainers: &[PublicKey],
|
||||
) -> RepoStatus
|
||||
where
|
||||
I: IntoIterator<Item = &'a Event>,
|
||||
{
|
||||
status_events
|
||||
.into_iter()
|
||||
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
||||
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
||||
.max_by_key(|e| e.created_at)
|
||||
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||
.unwrap_or(RepoStatus::Open)
|
||||
pub fn resolve<'a, I>(
|
||||
status_events: I,
|
||||
root_author: &PublicKey,
|
||||
maintainers: &[PublicKey],
|
||||
) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = &'a Event>,
|
||||
{
|
||||
status_events
|
||||
.into_iter()
|
||||
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
||||
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
||||
.max_by_key(|e| e.created_at)
|
||||
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||
.unwrap_or(RepoStatus::Open)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::GitEvent;
|
||||
|
||||
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
||||
@@ -76,7 +62,6 @@ mod tests {
|
||||
EventId::from_hex(ROOT_ID_HEX).expect("valid event id")
|
||||
}
|
||||
|
||||
/// Build a signed status event with a controlled `created_at`.
|
||||
fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags([Tag::event(root)])
|
||||
@@ -95,58 +80,8 @@ mod tests {
|
||||
))
|
||||
.expect("signed event");
|
||||
|
||||
assert!(references_root(&event, &root));
|
||||
assert!(!references_root(
|
||||
&event,
|
||||
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_root_matches_uppercase_e_tag() {
|
||||
let root = root_event_id();
|
||||
let event = EventBuilder::new(Kind::Comment, "")
|
||||
.tags([Tag::parse(["E", ROOT_ID_HEX]).expect("valid E tag")])
|
||||
.finalize(&keys_from_hex(
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
))
|
||||
.expect("signed event");
|
||||
|
||||
assert!(references_root(&event, &root));
|
||||
assert!(!references_root(
|
||||
&event,
|
||||
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_root_false_without_e_tags() {
|
||||
let event = EventBuilder::new(Kind::GitStatusOpen, "")
|
||||
.finalize(&keys_from_hex(
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
))
|
||||
.expect("signed event");
|
||||
|
||||
assert!(!references_root(&event, &root_event_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_open_without_status_events() {
|
||||
let owner =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
|
||||
let statuses: Vec<Event> = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(
|
||||
statuses.iter(),
|
||||
&owner.public_key(),
|
||||
&[maintainer.public_key()]
|
||||
),
|
||||
RepoStatus::Open
|
||||
);
|
||||
assert!(event.references_root(&root));
|
||||
assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -163,7 +98,7 @@ mod tests {
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(
|
||||
RepoStatus::resolve(
|
||||
statuses.iter(),
|
||||
&owner.public_key(),
|
||||
&[maintainer.public_key()]
|
||||
@@ -188,7 +123,7 @@ mod tests {
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(
|
||||
RepoStatus::resolve(
|
||||
statuses.iter(),
|
||||
&owner.public_key(),
|
||||
&[maintainer.public_key()]
|
||||
@@ -196,18 +131,4 @@ mod tests {
|
||||
RepoStatus::Draft
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_status_kinds() {
|
||||
let owner =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
|
||||
let root = root_event_id();
|
||||
|
||||
let statuses = [status_event(&owner, Kind::GitIssue, root, 100)];
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(statuses.iter(), &owner.public_key(), &[]),
|
||||
RepoStatus::Open
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
pub raw: String,
|
||||
pub addr: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
/// Parses a raw `u` tag value, extracting the coordinate when present.
|
||||
pub(crate) fn parse(raw: &str) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
.ok()
|
||||
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||
.map(RepoAddr::from);
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the coordinate when parsed, otherwise the raw value.
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ publish.workspace = true
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.56"
|
||||
gix-worktree-state = "0.34"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.57"
|
||||
gix-worktree-state = "0.35"
|
||||
diffy = "0.5"
|
||||
ignore = "0.4"
|
||||
|
||||
|
||||
@@ -1,96 +1,114 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::remote::{clone_repo, fetch_all};
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
clone_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl GitCache {
|
||||
/// Creates a cache rooted at `root`.
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
Self {
|
||||
root,
|
||||
clone_locks: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The root directory holding the mirror clones.
|
||||
/// Returns the cache root directory.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Local path of the clone for a repository.
|
||||
/// Returns the cache path for a repository address.
|
||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||
self.root
|
||||
.join(addr.public_key.to_hex())
|
||||
.join(sanitize_path_component(&addr.identifier))
|
||||
.join(addr.public_key().to_hex())
|
||||
.join(Self::sanitize_path_component(addr.identifier()))
|
||||
}
|
||||
|
||||
/// Open an existing clone.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||
/// Opens the cached repository, or `None` when it is not cloned yet.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||
let path = self.repo_path(addr);
|
||||
match gix::open(&path) {
|
||||
Ok(repo) => Ok(Some(repo)),
|
||||
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
||||
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Ok(repo) => Ok(Some(Repo::new(repo))),
|
||||
Err(e) if e.is_not_found() => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the existing clone, fetching it first.
|
||||
pub fn ensure_clone<U: AsRef<str>>(
|
||||
&self,
|
||||
addr: &RepoAddr,
|
||||
clone_urls: &[U],
|
||||
) -> Result<gix::Repository> {
|
||||
/// Returns the clone lock for `path`, creating it on first use.
|
||||
fn clone_lock(&self, path: &Path) -> Arc<Mutex<()>> {
|
||||
let mut locks = self
|
||||
.clone_locks
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
Arc::clone(locks.entry(path.to_path_buf()).or_default())
|
||||
}
|
||||
|
||||
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
|
||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
if let Some(repo) = self.open(addr)? {
|
||||
fetch_all(&repo).ok();
|
||||
// Lock the clone lock for this path to prevent concurrent clones.
|
||||
let clone_lock = self.clone_lock(&path);
|
||||
let _clone_guard = clone_lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
|
||||
// Refreshes the cached repository if it is already cloned, or clones it from one of `clone_urls` otherwise.
|
||||
if let Ok(Some(repo)) = self.open(addr) {
|
||||
if let Err(error) = repo.fetch() {
|
||||
log::warn!("failed to refresh the cached repository: {error:#}");
|
||||
}
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
// Create the parent directory if it does not exist.
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
clone_repo(clone_urls, &path)?;
|
||||
self.open(addr)?
|
||||
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
|
||||
}
|
||||
}
|
||||
// Remove the cached clone if it exists but is unusable.
|
||||
if path.exists() {
|
||||
log::warn!("removing unusable cached clone at {}", path.display());
|
||||
std::fs::remove_dir_all(&path)?;
|
||||
}
|
||||
|
||||
/// Map an untrusted repository id or display name to a safe single path component.
|
||||
///
|
||||
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
||||
/// An id that maps to exactly `.` or `..` becomes `_`.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if sanitized == "." || sanitized == ".." {
|
||||
return "_".to_owned();
|
||||
Repo::clone(clone_urls, &path)
|
||||
}
|
||||
|
||||
sanitized
|
||||
}
|
||||
/// Maps untrusted relay content onto a safe single path component.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
/// The refs namespace of a fork's import in the target mirror.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
announcement.owner.to_hex(),
|
||||
sanitize_path_component(&announcement.id)
|
||||
)
|
||||
if sanitized == "." || sanitized == ".." {
|
||||
return "_".to_owned();
|
||||
}
|
||||
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// Returns the `owner/identifier` namespace used for fork checkouts.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
announcement.owner.to_hex(),
|
||||
Self::sanitize_path_component(&announcement.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+159
-226
@@ -1,46 +1,60 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
|
||||
/// The kind of a [`DiffLine`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
/// An unchanged context line, present on both sides.
|
||||
Context,
|
||||
/// A line added by the commit.
|
||||
Addition,
|
||||
/// A line removed by the commit.
|
||||
Deletion,
|
||||
}
|
||||
|
||||
/// One line of a file diff.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
/// 1-based line number in the old version, if the line exists there.
|
||||
pub old: Option<u32>,
|
||||
/// 1-based line number in the new version, if the line exists there.
|
||||
pub new: Option<u32>,
|
||||
/// Line content without the trailing newline.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||
impl DiffLine {
|
||||
/// Creates a diff line with its kind, optional line numbers, and text.
|
||||
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
old,
|
||||
new,
|
||||
text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
/// Number of old lines covered by the hunk.
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
/// Number of new lines covered by the hunk.
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
/// How a file changed in a commit.
|
||||
impl DiffHunk {
|
||||
/// Creates a hunk spanning the given old and new ranges.
|
||||
pub fn new(
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
lines: Vec<DiffLine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
@@ -50,255 +64,174 @@ pub enum DiffStatus {
|
||||
Copied,
|
||||
}
|
||||
|
||||
/// The diff of one file in a commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root.
|
||||
///
|
||||
/// For renames and copies, this is the destination path.
|
||||
pub path: String,
|
||||
/// Previous path, for renames and copies.
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
/// Number of added lines, 0 for binary files.
|
||||
pub insertions: usize,
|
||||
/// Number of removed lines, 0 for binary files.
|
||||
pub deletions: usize,
|
||||
/// True if either version is binary, then `hunks` is empty.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
/// The changes of one commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
/// 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 commit.
|
||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||
commit_diff(&gix::open(workdir)?, id)
|
||||
}
|
||||
|
||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = match commit.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
tree_diff(repo, old_tree.as_ref(), &new_tree)
|
||||
}
|
||||
|
||||
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
|
||||
///
|
||||
/// 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 = gix::open(workdir)?;
|
||||
let base_tree = repo
|
||||
.rev_parse_single(base.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
let tip_tree = repo
|
||||
.rev_parse_single(tip.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
tree_diff(&repo, Some(&base_tree), &tip_tree)
|
||||
}
|
||||
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
||||
fn tree_diff(
|
||||
repo: &gix::Repository,
|
||||
old_tree: Option<&gix::Tree<'_>>,
|
||||
new_tree: &gix::Tree<'_>,
|
||||
) -> Result<CommitDiff> {
|
||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||
use gix::object::tree::diff::Change;
|
||||
use gix::objs::tree::EntryKind;
|
||||
|
||||
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||
|
||||
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
for change in changes {
|
||||
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
||||
|
||||
// Skip directory trees and submodule gitlinks, only files are listed.
|
||||
let (path, old_path, status) = match attached {
|
||||
Change::Addition {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Added)
|
||||
}
|
||||
Change::Deletion {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Deleted)
|
||||
}
|
||||
Change::Modification {
|
||||
location,
|
||||
previous_entry_mode,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
previous_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
(location.to_owned(), None, DiffStatus::Modified)
|
||||
}
|
||||
Change::Rewrite {
|
||||
location,
|
||||
source_location,
|
||||
source_entry_mode,
|
||||
entry_mode,
|
||||
copy,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
source_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
let status = if copy {
|
||||
DiffStatus::Copied
|
||||
} else {
|
||||
DiffStatus::Renamed
|
||||
};
|
||||
(
|
||||
location.to_owned(),
|
||||
Some(source_location.to_owned()),
|
||||
status,
|
||||
)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Always diff with the built-in algorithm.
|
||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||
let platform = attached.diff(&mut cache)?;
|
||||
platform
|
||||
.resource_cache
|
||||
.options
|
||||
.skip_internal_diff_if_external_is_configured = true;
|
||||
let outcome = platform.resource_cache.prepare_diff()?;
|
||||
|
||||
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||
Operation::InternalDiff { algorithm } => {
|
||||
let input = outcome.interned_input();
|
||||
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let collector = HunkCollector {
|
||||
hunks: &mut hunks,
|
||||
insertions: &mut insertions,
|
||||
deletions: &mut deletions,
|
||||
};
|
||||
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||
.consume()?;
|
||||
(false, hunks, insertions, deletions)
|
||||
}
|
||||
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
||||
};
|
||||
|
||||
files.push(FileDiff {
|
||||
path: String::from_utf8_lossy(&path).into_owned(),
|
||||
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||
impl FileDiff {
|
||||
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
|
||||
pub fn new(
|
||||
path: String,
|
||||
old_path: Option<String>,
|
||||
status: DiffStatus,
|
||||
insertions: usize,
|
||||
deletions: usize,
|
||||
binary: bool,
|
||||
hunks: Vec<DiffHunk>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
old_path,
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary,
|
||||
hunks,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
struct HunkCollector<'a> {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
impl CommitDiff {
|
||||
/// Creates a commit diff from its files.
|
||||
pub fn new(files: Vec<FileDiff>) -> Self {
|
||||
Self { files }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct HunkBuilder {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
old: u32,
|
||||
new: u32,
|
||||
lines: Vec<DiffLine>,
|
||||
insertions: usize,
|
||||
deletions: usize,
|
||||
}
|
||||
|
||||
impl HunkBuilder {
|
||||
/// Starts a hunk spanning the given old and new ranges.
|
||||
pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
|
||||
Self {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
old: old_start,
|
||||
new: new_start,
|
||||
lines: Vec::new(),
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a line, numbering it and counting insertions and deletions.
|
||||
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(self.old), Some(self.new));
|
||||
self.old += 1;
|
||||
self.new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
self.insertions += 1;
|
||||
let number = Some(self.new);
|
||||
self.new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
self.deletions += 1;
|
||||
let number = Some(self.old);
|
||||
self.old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
self.lines
|
||||
.push(DiffLine::new(kind, old_no, new_no, text.into()));
|
||||
}
|
||||
|
||||
/// Finishes the hunk and returns it with its insertion and deletion counts.
|
||||
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
|
||||
let hunk = DiffHunk::new(
|
||||
self.old_start,
|
||||
self.old_lines,
|
||||
self.new_start,
|
||||
self.new_lines,
|
||||
self.lines,
|
||||
);
|
||||
(hunk, self.insertions, self.deletions)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl<'a> HunkCollector<'a> {
|
||||
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
|
||||
pub(crate) fn new(
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
hunks,
|
||||
insertions,
|
||||
deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConsumeHunk for HunkCollector<'_> {
|
||||
type Out = ();
|
||||
|
||||
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
|
||||
fn consume_hunk(
|
||||
&mut self,
|
||||
header: HunkHeader,
|
||||
lines: &[(GixLineKind, &[u8])],
|
||||
) -> std::io::Result<()> {
|
||||
let mut old_ln = header.before_hunk_start;
|
||||
let mut new_ln = header.after_hunk_start;
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
let mut builder = HunkBuilder::new(
|
||||
header.before_hunk_start,
|
||||
header.before_hunk_len,
|
||||
header.after_hunk_start,
|
||||
header.after_hunk_len,
|
||||
);
|
||||
|
||||
for (kind, content) in lines {
|
||||
let text = String::from_utf8_lossy(content).into_owned();
|
||||
let line = match kind {
|
||||
GixLineKind::Context => {
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Context,
|
||||
old: Some(old_ln),
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Remove => {
|
||||
*self.deletions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Deletion,
|
||||
old: Some(old_ln),
|
||||
new: None,
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Add => {
|
||||
*self.insertions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Addition,
|
||||
old: None,
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
let kind = match kind {
|
||||
GixLineKind::Context => DiffLineKind::Context,
|
||||
GixLineKind::Remove => DiffLineKind::Deletion,
|
||||
GixLineKind::Add => DiffLineKind::Addition,
|
||||
};
|
||||
out.push(line);
|
||||
builder.push(kind, String::from_utf8_lossy(content).into_owned());
|
||||
}
|
||||
|
||||
self.hunks.push(DiffHunk {
|
||||
old_start: header.before_hunk_start,
|
||||
old_lines: header.before_hunk_len,
|
||||
new_start: header.after_hunk_start,
|
||||
new_lines: header.after_hunk_len,
|
||||
lines: out,
|
||||
});
|
||||
let (hunk, insertions, deletions) = builder.finish();
|
||||
*self.insertions += insertions;
|
||||
*self.deletions += deletions;
|
||||
self.hunks.push(hunk);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks the end of the unified diff stream.
|
||||
fn finish(self) {}
|
||||
}
|
||||
|
||||
@@ -1,267 +1,77 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// In-memory object cache for history walks, see [`open_with_cache`].
|
||||
///
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
||||
pub id: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Rest of the commit message after the title.
|
||||
///
|
||||
/// `None` for single-line commit messages.
|
||||
pub description: Option<String>,
|
||||
/// Author name.
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) 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 commit, with author, message title, body and shortened id.
|
||||
///
|
||||
/// The diff panel fetches the full commit on demand.
|
||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
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 {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
description: if include_description {
|
||||
message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
.filter(|body| !body.is_empty())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
|
||||
///
|
||||
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
|
||||
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||
let rel = rel.to_path_buf();
|
||||
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, commit)| commit))
|
||||
}
|
||||
|
||||
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||
/// `rels` are paths relative to the worktree.
|
||||
///
|
||||
/// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn worktree_last_commits(
|
||||
workdir: &Path,
|
||||
rels: &[PathBuf],
|
||||
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
last_commits(&open_with_cache(workdir)?, rels)
|
||||
}
|
||||
|
||||
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
||||
///
|
||||
/// Stops as soon as every pending path has its commit.
|
||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// De-duplicate while preserving order.
|
||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||
|
||||
for rel in rels {
|
||||
if seen.insert(rel.as_path()) {
|
||||
pending.push(rel.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut found = Vec::new();
|
||||
for info in walk.all()? {
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
let info = info?;
|
||||
let commit = info.object()?;
|
||||
let tree = commit.tree()?;
|
||||
let parent_tree = match info.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Compare each unresolved path against this commit and its first parent.
|
||||
// Resolved paths leave the pending set.
|
||||
let mut ix = 0;
|
||||
while ix < pending.len() {
|
||||
let rel = &pending[ix];
|
||||
let blob = tree.lookup_entry_by_path(rel)?;
|
||||
let parent_blob = match &parent_tree {
|
||||
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||
{
|
||||
found.push((rel.clone(), file_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
|
||||
/// the tab badge shows the real count.
|
||||
///
|
||||
/// A huge history is never fully materialized in memory.
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
|
||||
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
||||
pub struct CommitList {
|
||||
/// Number of commits reachable from HEAD.
|
||||
pub total: usize,
|
||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||
///
|
||||
/// Returns an empty list for a repository without any commits yet.
|
||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
impl CommitList {
|
||||
/// Creates a list holding `total` commits of which `commits` are materialized.
|
||||
pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
|
||||
Self { total, commits }
|
||||
}
|
||||
}
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
pub id: String,
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub author: String,
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut total = 0;
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
total += 1;
|
||||
if commits.len() < MAX_LISTED_COMMITS {
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
impl FileCommit {
|
||||
/// Creates a commit entry from its id, message parts, author, and timestamp.
|
||||
pub fn new(
|
||||
id: String,
|
||||
summary: String,
|
||||
description: Option<String>,
|
||||
author: String,
|
||||
time: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
summary,
|
||||
description,
|
||||
author,
|
||||
time,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
/// Converts a gix commit including its message body.
|
||||
pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||
Self::from_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
/// Like [`all_commits`], but opens the repository at `workdir` first.
|
||||
///
|
||||
/// For non-bare clones the clone root is the worktree.
|
||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||
all_commits(&open_with_cache(workdir)?)
|
||||
}
|
||||
/// Converts a gix commit, skipping the message body since lists never show it.
|
||||
pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||
Self::from_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
|
||||
pub fn worktree_commit_range_commits(
|
||||
workdir: &Path,
|
||||
base: &str,
|
||||
tip: &str,
|
||||
) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
/// Converts a gix commit, optionally skipping the message body.
|
||||
fn from_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
) -> anyhow::Result<Self> {
|
||||
use crate::GixResultExt as _;
|
||||
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let base_id = repo.rev_parse_single(base.as_bytes())?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
let walk = repo
|
||||
.rev_walk([tip_id])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
let author = commit.author().into_anyhow()?;
|
||||
let message = commit.message().into_anyhow()?;
|
||||
|
||||
Ok(FileCommit::new(
|
||||
commit.id().shorten_or_id().to_string(),
|
||||
String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
if include_description {
|
||||
message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
.filter(|body| !body.is_empty())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
author.time().into_anyhow()?.seconds,
|
||||
))
|
||||
.with_hidden([base_id]);
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
/// The commit HEAD points to, like `git log -1`.
|
||||
///
|
||||
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
|
||||
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
||||
/// Like [`head_commit`] for an arbitrary commit.
|
||||
///
|
||||
/// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
||||
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)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
mod cache;
|
||||
mod diff;
|
||||
mod history;
|
||||
mod nip34;
|
||||
mod patch;
|
||||
mod remote;
|
||||
mod repo;
|
||||
mod scan;
|
||||
mod sync;
|
||||
mod worktree;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use cache::{GitCache, fork_namespace, sanitize_path_component};
|
||||
pub use diff::{
|
||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff,
|
||||
worktree_commit_range_diff,
|
||||
};
|
||||
pub use history::{
|
||||
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit,
|
||||
worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
||||
};
|
||||
pub use patch::{
|
||||
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
|
||||
};
|
||||
pub use remote::{
|
||||
clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref,
|
||||
push_main, remote_has_refs, set_origin,
|
||||
};
|
||||
pub use repo::{
|
||||
RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches,
|
||||
head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state,
|
||||
repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists,
|
||||
worktree_ref_state,
|
||||
};
|
||||
pub use scan::find_git_repos;
|
||||
pub use worktree::{
|
||||
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag,
|
||||
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
|
||||
};
|
||||
pub use cache::GitCache;
|
||||
pub use diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
pub use history::{CommitList, FileCommit, MAX_LISTED_COMMITS};
|
||||
pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
|
||||
pub use patch::PatchParser;
|
||||
pub use repo::{Repo, RepoRefState};
|
||||
pub use scan::{LocalRepo, find_git_repos};
|
||||
pub use sync::{RefSync, RepoSyncStatus};
|
||||
pub use worktree::WorktreeSnapshot;
|
||||
|
||||
pub(crate) trait GixResultExt<T> {
|
||||
/// Converts a gix exception result into an `anyhow` result.
|
||||
fn into_anyhow(self) -> anyhow::Result<T>;
|
||||
}
|
||||
|
||||
impl<T, E> GixResultExt<T> for Result<T, gix::Exn<E>>
|
||||
where
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
fn into_anyhow(self) -> anyhow::Result<T> {
|
||||
self.map_err(|exn| anyhow::Error::from(exn.into_error()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, returning trimmed stdout.
|
||||
///
|
||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
||||
#[cfg(test)]
|
||||
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = remote::git_output(dir, args, "git")?;
|
||||
let output = Repo::run_git(dir, args, "git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct Nip34Json {
|
||||
pub(crate) identifier: Option<String>,
|
||||
pub(crate) owner: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Nip34Kind {
|
||||
Initialized,
|
||||
Cloned,
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct GraspSignals {
|
||||
pub nip34_json: bool,
|
||||
pub nip34_excluded: bool,
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
pub nostr_aux_config: bool,
|
||||
pub maintainers_yaml: bool,
|
||||
}
|
||||
|
||||
impl GraspSignals {
|
||||
/// Returns whether any Nostr tooling marker was found.
|
||||
pub fn any(&self) -> bool {
|
||||
*self != Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl Nip34Binding {
|
||||
/// Creates a binding from its kind, detected signals, and recovered coordinates.
|
||||
pub fn new(
|
||||
kind: Nip34Kind,
|
||||
signals: GraspSignals,
|
||||
owner: Option<PublicKey>,
|
||||
identifier: Option<String>,
|
||||
grasp_urls: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
signals,
|
||||
owner,
|
||||
identifier,
|
||||
grasp_urls,
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
-303
@@ -1,323 +1,238 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::Result;
|
||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||
use diffy::{Hunk, Line};
|
||||
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
|
||||
use crate::history::FileCommit;
|
||||
|
||||
/// Apply a `git format-patch` patch or series with `git am`,
|
||||
/// uses the git CLI because it handles the mbox format natively.
|
||||
///
|
||||
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("am")
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
pub struct PatchParser;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin piped")
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
impl PatchParser {
|
||||
/// Splits an mbox series into the raw text of each message.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.map(|message| message.text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
/// Parses a git diff into file diffs, returning an empty diff without one.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff::new(Vec::new()));
|
||||
}
|
||||
|
||||
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
||||
/// Fails when the range has no commits.
|
||||
///
|
||||
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
|
||||
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git format-patch`")?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git format-patch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(Self::file_diff(file?));
|
||||
}
|
||||
|
||||
Ok(CommitDiff::new(files))
|
||||
}
|
||||
|
||||
/// Lists the commits represented by the mbox messages that carry an id.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.filter(|message| !message.id.is_empty())
|
||||
.map(|message| {
|
||||
FileCommit::new(
|
||||
message.id.to_string(),
|
||||
message
|
||||
.header("Subject")
|
||||
.map(Self::strip_patch_prefix)
|
||||
.unwrap_or_default(),
|
||||
None,
|
||||
message
|
||||
.header("From")
|
||||
.map(Self::name_from_address)
|
||||
.unwrap_or_default(),
|
||||
message
|
||||
.header("Date")
|
||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||
.map(|time| time.seconds)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
|
||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||
let mut headers_closed = false;
|
||||
|
||||
let mut offset = 0usize;
|
||||
for line in patch.lines() {
|
||||
let line_start = offset;
|
||||
offset += line.len() + 1;
|
||||
|
||||
let is_envelope = line
|
||||
.strip_prefix("From ")
|
||||
.and_then(|rest| rest.split_whitespace().next())
|
||||
.is_some_and(|id| id.len() == 40);
|
||||
|
||||
if is_envelope {
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||
}
|
||||
|
||||
let id = line
|
||||
.strip_prefix("From ")
|
||||
.and_then(|rest| rest.split_whitespace().next())
|
||||
.unwrap_or("");
|
||||
|
||||
current = Some((line_start, id, Vec::new()));
|
||||
headers_closed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((_, _, headers)) = &mut current {
|
||||
if headers_closed {
|
||||
continue;
|
||||
}
|
||||
if line.is_empty() {
|
||||
headers_closed = true;
|
||||
} else {
|
||||
headers.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
messages.push(Envelope::new(patch, "", Vec::new()));
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Extracts the display name from a `From` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
None => from.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let Some(end) = rest.find(']') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||
rest[end + 1..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
operation => {
|
||||
stripped = operation.strip_prefix(1);
|
||||
&stripped
|
||||
}
|
||||
};
|
||||
|
||||
let (path, old_path, status) = match operation {
|
||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||
FileOperation::Modify { modified, .. } => {
|
||||
(modified.as_ref(), None, DiffStatus::Modified)
|
||||
}
|
||||
FileOperation::Rename { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||
}
|
||||
FileOperation::Copy { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Copied)
|
||||
}
|
||||
};
|
||||
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let mut hunks = Vec::new();
|
||||
|
||||
let patch = file.patch();
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let (hunk, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
|
||||
insertions += hunk_insertions;
|
||||
deletions += hunk_deletions;
|
||||
hunks.push(hunk);
|
||||
}
|
||||
}
|
||||
|
||||
FileDiff::new(
|
||||
path.to_owned(),
|
||||
old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
patch.is_binary(),
|
||||
hunks,
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
let mut builder = HunkBuilder::new(
|
||||
old_range.start() as u32,
|
||||
old_range.len() as u32,
|
||||
new_range.start() as u32,
|
||||
new_range.len() as u32,
|
||||
);
|
||||
}
|
||||
|
||||
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||
};
|
||||
|
||||
if patch.trim().is_empty() {
|
||||
bail!("no commits between {base} and {tip}");
|
||||
}
|
||||
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
||||
///
|
||||
/// A single patch yields one element.
|
||||
/// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
let mut starts = vec![0usize];
|
||||
let mut search_from = 1;
|
||||
|
||||
while let Some(rel) = patch[search_from..].find("\nFrom ") {
|
||||
let ix = search_from + rel + 1;
|
||||
let hex = patch[ix + 5..]
|
||||
.split(|c: char| !c.is_ascii_hexdigit())
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if hex.len() == 40 {
|
||||
starts.push(ix);
|
||||
}
|
||||
search_from = ix + 1;
|
||||
}
|
||||
|
||||
starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &start)| {
|
||||
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
|
||||
&patch[start..end]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
||||
/// `diff --git` headers, rename and copy detection, binary detection, and
|
||||
/// C-style quoted or octal-escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(file_diff(file?)?);
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// The [`FileDiff`] of one parsed file patch.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
// Rename and copy paths come from their own headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
operation => {
|
||||
stripped = operation.strip_prefix(1);
|
||||
&stripped
|
||||
}
|
||||
};
|
||||
|
||||
let (path, old_path, status) = match operation {
|
||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
|
||||
FileOperation::Rename { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||
}
|
||||
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
|
||||
};
|
||||
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let mut hunks = Vec::new();
|
||||
|
||||
let patch = file.patch();
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let hunk = hunk_diff(hunk);
|
||||
insertions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
||||
.count();
|
||||
deletions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
||||
.count();
|
||||
hunks.push(hunk);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: patch.is_binary(),
|
||||
hunks,
|
||||
})
|
||||
}
|
||||
|
||||
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
|
||||
///
|
||||
/// `diffy` reports only the hunk header ranges. The per-line numbers are
|
||||
/// counted from them the way the header encodes them: context lines advance
|
||||
/// both sides, deletions only the old, insertions only the new.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
let mut old = old_range.start() as u32;
|
||||
let mut new = new_range.start() as u32;
|
||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
||||
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||
};
|
||||
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(old), Some(new));
|
||||
old += 1;
|
||||
new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
let number = Some(new);
|
||||
new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
let number = Some(old);
|
||||
old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old: old_no,
|
||||
new: new_no,
|
||||
text: line_text(text),
|
||||
});
|
||||
}
|
||||
|
||||
DiffHunk {
|
||||
old_start: old_range.start() as u32,
|
||||
old_lines: old_range.len() as u32,
|
||||
new_start: new_range.start() as u32,
|
||||
new_lines: new_range.len() as u32,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a parsed line without its line ending.
|
||||
///
|
||||
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
}
|
||||
|
||||
/// Commits of a `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
let lines: Vec<&str> = patch.lines().collect();
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
// A patch starts with its `From <id> <date>` envelope line.
|
||||
let Some(rest) = lines[i].strip_prefix("From ") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(id) = rest.split_whitespace().next() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if id.len() != 40 {
|
||||
i += 1;
|
||||
continue;
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
let text = text.strip_suffix('\r').unwrap_or(text);
|
||||
builder.push(kind, text.to_owned());
|
||||
}
|
||||
|
||||
let mut author = String::new();
|
||||
let mut summary = String::new();
|
||||
let mut time = 0i64;
|
||||
|
||||
// Envelope headers run up to the blank line before the commit message.
|
||||
i += 1;
|
||||
while i < lines.len() && !lines[i].is_empty() {
|
||||
let header = lines[i];
|
||||
if let Some(value) = header.strip_prefix("From: ") {
|
||||
author = name_from_address(value);
|
||||
} else if let Some(value) = header.strip_prefix("Subject: ") {
|
||||
summary = strip_patch_prefix(value);
|
||||
} else if let Some(value) = header.strip_prefix("Date: ") {
|
||||
time = gix::date::parse(value.trim(), None)
|
||||
.map(|t| t.seconds)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
commits.push(FileCommit {
|
||||
id: id.to_string(),
|
||||
summary,
|
||||
description: None,
|
||||
author,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
/// The name part of a `From: Name <email>` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
None => from.trim().to_string(),
|
||||
builder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the patch prefix from a `Subject:` header.
|
||||
///
|
||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let Some(end) = rest.find(']') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||
rest[end + 1..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
struct Envelope<'a> {
|
||||
text: &'a str,
|
||||
id: &'a str,
|
||||
headers: Vec<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Envelope<'a> {
|
||||
/// Creates an envelope from its text, commit id, and header lines.
|
||||
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
|
||||
Self { text, id, headers }
|
||||
}
|
||||
|
||||
/// Returns the first header line matching `name: `.
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
let prefix = format!("{name}: ");
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|line| line.starts_with(&prefix))
|
||||
.map(|line| &line[prefix.len()..])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gix::interrupt::IS_INTERRUPTED;
|
||||
use gix::progress::Discard;
|
||||
|
||||
/// Clone into `path` from the first working URL in `clone_urls`.
|
||||
///
|
||||
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
bail!("destination {} already exists", path.display());
|
||||
}
|
||||
|
||||
try_each_url(clone_urls, "clone", |url| {
|
||||
let repo = clone(url, path)?;
|
||||
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)?
|
||||
.to_owned(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
repo.find_remote("origin")?
|
||||
.connect(gix::remote::Direction::Fetch)?
|
||||
.prepare_fetch(Discard, options)?
|
||||
.receive(Discard, &IS_INTERRUPTED)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
||||
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(url)
|
||||
.arg(format!("{commit}:{reference}"))
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||
///
|
||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
/// Run `attempt` against each URL in `urls` until one succeeds.
|
||||
///
|
||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||
/// or `no clone URLs provided` when the list is empty.
|
||||
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str) -> Result<()>,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in urls {
|
||||
match attempt(url.as_ref()) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
let url = transport_url(url);
|
||||
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||
|
||||
let mut prepare = gix::prepare_clone(url, path)?;
|
||||
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
|
||||
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||
///
|
||||
/// This mirrors an initialized repository's whole history.
|
||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||
fn push_refspecs(
|
||||
repo_path: &Path,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
repo_id: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||
args.push("push");
|
||||
args.push(&url);
|
||||
args.extend_from_slice(refspecs);
|
||||
|
||||
let output = git_output(repo_path, &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `url` advertises every ref in `expected` at the given commit.
|
||||
///
|
||||
/// Extra advertised refs are ignored: the question is whether the data this
|
||||
/// push wanted to land is already there, not whether the remote is an exact mirror.
|
||||
/// This is the convergence probe for a push that lost the compare-and-swap race
|
||||
/// to the grasp server's own background ref alignment.
|
||||
pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let repo = gix::open(repo_path)?;
|
||||
let url = transport_url(url);
|
||||
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
|
||||
// keeps refs that match one. Match each expected ref by its exact name,
|
||||
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
|
||||
let refspecs = expected
|
||||
.iter()
|
||||
.map(|(name, _)| {
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.map(|spec| spec.to_owned())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("invalid refspec")?;
|
||||
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: refspecs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (refs, _) = repo
|
||||
.remote_at(url.as_str())
|
||||
.with_context(|| format!("cannot use remote {url}"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("cannot connect to {url}"))?
|
||||
.ref_map(Discard, options)
|
||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||
|
||||
// Peeled tag entries carry the tag object in their direct oid, so mapping
|
||||
// each advertised ref to its direct oid matches `git ls-remote` while
|
||||
// skipping the duplicated `^{}` lines.
|
||||
let advertised: HashMap<String, String> = refs
|
||||
.remote_refs
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
let (name, object, _peeled) = reference.unpack();
|
||||
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(expected
|
||||
.iter()
|
||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||
}
|
||||
|
||||
/// Add `origin` pointing at `url` when the repository has no remote yet.
|
||||
///
|
||||
/// No-op if `origin` already exists.
|
||||
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
if repo.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `git remote add` also configures the default fetch refspec.
|
||||
edit_local_config(&repo, |config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Point `origin` at `url`, replacing an existing remote,
|
||||
/// used after a clone whose `origin` points at the cloned-from path.
|
||||
///
|
||||
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
|
||||
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
let had_origin = repo.find_remote("origin").is_ok();
|
||||
|
||||
edit_local_config(&repo, |config| {
|
||||
// Replaces the existing url, like `git remote set-url origin <url>`.
|
||||
// A pre-existing fetch refspec is left untouched.
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
|
||||
if !had_origin {
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `edit` to the repository-local configuration and persist it.
|
||||
///
|
||||
/// The config file is locked while it is read, edited and written back,
|
||||
/// like git would when running `git config` or `git remote`.
|
||||
fn edit_local_config(
|
||||
repo: &gix::Repository,
|
||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let config_path = repo.common_dir().join("config");
|
||||
|
||||
let mut lock = gix::lock::File::acquire_to_update_resource(
|
||||
&config_path,
|
||||
gix::lock::acquire::Fail::Immediately,
|
||||
None,
|
||||
)
|
||||
.context("failed to lock repository config")?;
|
||||
|
||||
let mut config =
|
||||
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) {
|
||||
Ok(config) => config,
|
||||
// A repository without a config file yet starts from scratch.
|
||||
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
||||
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||
{
|
||||
gix::config::File::default()
|
||||
}
|
||||
Err(error) => return Err(error).context("failed to read repository config"),
|
||||
};
|
||||
|
||||
edit(&mut config)?;
|
||||
|
||||
config
|
||||
.write_to(&mut lock)
|
||||
.context("failed to write repository config")?;
|
||||
|
||||
lock.commit().context("failed to save repository config")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
|
||||
/// When no URL works, the last error is returned.
|
||||
///
|
||||
/// Never touches the checked-out refs or the worktree.
|
||||
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.context("invalid fetch refspec")?
|
||||
.to_owned();
|
||||
|
||||
try_each_url(urls, "fetch", |url| {
|
||||
let url = transport_url(url);
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![refspec.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
repo.remote_at(url.as_str())
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.prepare_fetch(Discard, options)
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.receive(Discard, &IS_INTERRUPTED)
|
||||
.with_context(|| format!("fetch from {url} failed"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The URL of the `origin` remote of the repository at `workdir`.
|
||||
///
|
||||
/// `None` when it has no `origin` yet.
|
||||
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Ok(remote) = repo.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(remote
|
||||
.url(gix::remote::Direction::Fetch)
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||
///
|
||||
/// `what` names the command in the spawn error.
|
||||
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
+1786
-410
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,82 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
/// Maximum directory nesting depth when scanning for local repositories.
|
||||
///
|
||||
/// Pathological trees can't stall the scan.
|
||||
use crate::nip34::Nip34Binding;
|
||||
use crate::repo::Repo;
|
||||
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
/// Walk `root` recursively and collect the paths of git repositories below it,
|
||||
/// honouring `.gitignore` (and `.ignore`) files.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepo {
|
||||
pub path: PathBuf,
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
}
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
impl LocalRepo {
|
||||
/// Creates a local repository entry from its path and optional NIP-34 binding.
|
||||
pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
|
||||
Self { path, nip34 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
|
||||
|
||||
let walker = WalkBuilder::new(&root)
|
||||
.max_depth(Some(SCAN_MAX_DEPTH))
|
||||
// Honour `.gitignore` even when the scan root is not itself a repository.
|
||||
.require_git(false)
|
||||
.filter_entry({
|
||||
let found = found.clone();
|
||||
move |entry| {
|
||||
let Some(kind) = entry.file_type() else {
|
||||
return true;
|
||||
};
|
||||
if !kind.is_dir() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dir = entry.path();
|
||||
let Ok(repo) = gix::discover(dir) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if workdir != dir {
|
||||
return true;
|
||||
}
|
||||
|
||||
match found.lock() {
|
||||
Ok(mut repos) => repos.push(dir.to_path_buf()),
|
||||
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
|
||||
}
|
||||
false
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
let mut repos: Vec<PathBuf> = walker
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
|
||||
.map(ignore::DirEntry::into_path)
|
||||
.filter(|dir| dir.join(".git").exists())
|
||||
.filter_map(|dir| dir.canonicalize().ok())
|
||||
.collect();
|
||||
for _ in walker {}
|
||||
|
||||
let mut repos = match found.lock() {
|
||||
Ok(mut repos) => std::mem::take(&mut *repos),
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
};
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
|
||||
// A repository nested inside another, like a submodule worktree, is not reported.
|
||||
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
|
||||
for repo in repos {
|
||||
if !roots.iter().any(|kept| repo.starts_with(kept)) {
|
||||
roots.push(repo);
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
repos
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
||||
LocalRepo::new(path, nip34)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How a single branch compares between local and Nostr.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RefSync {
|
||||
InSync,
|
||||
LocalAhead { ahead: usize },
|
||||
RemoteAhead { behind: usize },
|
||||
Diverged { ahead: usize, behind: usize },
|
||||
LocalOnly,
|
||||
RemoteOnly,
|
||||
}
|
||||
|
||||
/// Branch comparison between a local repository and its Nostr state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoSyncStatus {
|
||||
/// Branch short name and its classification.
|
||||
pub refs: Vec<(String, RefSync)>,
|
||||
pub ahead_total: usize,
|
||||
pub behind_total: usize,
|
||||
}
|
||||
|
||||
impl RepoSyncStatus {
|
||||
/// True when every branch matches the Nostr state.
|
||||
pub fn in_sync(&self) -> bool {
|
||||
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
|
||||
}
|
||||
|
||||
/// Maps branch short name to lowercase commit oid.
|
||||
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
|
||||
refs.iter()
|
||||
.filter_map(|(name, commit)| {
|
||||
name.strip_prefix("refs/heads/")
|
||||
.map(|branch| (branch, commit.to_ascii_lowercase()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+423
-1366
File diff suppressed because it is too large
Load Diff
@@ -1,354 +1,36 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gix::progress::Discard;
|
||||
use crate::history::FileCommit;
|
||||
|
||||
use crate::history::{FileCommit, head_commit};
|
||||
use crate::repo::{current_branch, repository_signature};
|
||||
|
||||
/// Whether the worktree of `workdir` has uncommitted changes.
|
||||
///
|
||||
/// Best-effort: any read failure is reported as clean.
|
||||
pub fn worktree_dirty(workdir: &Path) -> bool {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Changes to tracked files, staged or not; untracked files are excluded.
|
||||
match repo.is_dirty() {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
|
||||
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
|
||||
// tracked files only appear there when modified.
|
||||
let Ok(platform) = repo.status(Discard) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for change in changes.by_ref() {
|
||||
match change {
|
||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||
Ok(_) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `workdir`.
|
||||
///
|
||||
/// Best-effort: 0 when the range cannot be computed.
|
||||
pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch))
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Resolve `rev` to a commit id, accepting full refs,
|
||||
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
|
||||
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
||||
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
// Branch names arrive bare, like git resolving `main`.
|
||||
if rev.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Relative paths of all entries in the worktree, files and directories.
|
||||
///
|
||||
/// The `.git` directory is skipped.
|
||||
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||
|
||||
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||
collect_entries(workdir, workdir, &mut entries)?;
|
||||
|
||||
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
||||
b_is_dir
|
||||
.cmp(a_is_dir)
|
||||
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
||||
});
|
||||
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||
}
|
||||
|
||||
/// Read a file from the worktree.
|
||||
///
|
||||
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
||||
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||
let path = workdir.join(rel);
|
||||
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the README file in the repository root.
|
||||
///
|
||||
/// Falls back to any other file whose name starts with `readme`.
|
||||
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for entry in std::fs::read_dir(workdir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if name.to_ascii_lowercase().starts_with("readme") {
|
||||
candidates.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by_key(|path| {
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||
match ext.as_deref() {
|
||||
Some("md") => 0,
|
||||
Some("markdown") => 1,
|
||||
Some("mdown") => 2,
|
||||
Some("mkdn") => 3,
|
||||
Some(_) => 5,
|
||||
None => 4,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
pub struct WorktreeSnapshot {
|
||||
/// Relative paths of all worktree entries, directories first.
|
||||
pub entries: Vec<PathBuf>,
|
||||
/// README path relative to the worktree, if any.
|
||||
pub readme_path: Option<PathBuf>,
|
||||
/// Contents of the README, if any.
|
||||
pub readme: Option<Vec<u8>>,
|
||||
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
/// Commit HEAD points to, if any, see [`head_commit`].
|
||||
pub head_commit: Option<FileCommit>,
|
||||
pub branches: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Snapshot the worktree after a branch or tag switch.
|
||||
///
|
||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let readme_path = find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => worktree_read(&repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(WorktreeSnapshot {
|
||||
entries: worktree_entries(&repo)?,
|
||||
readme_path,
|
||||
readme,
|
||||
current_branch: current_branch(&repo)?,
|
||||
head_commit: head_commit(&repo)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check out `tree` into the worktree of `repo`
|
||||
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
let mut index = repo.index_from_tree(tree)?;
|
||||
|
||||
// Files the previous index tracked but `tree` no longer contains are removed,
|
||||
// like git deleting files that vanish between branches.
|
||||
if let Ok(previous) = repo.index_or_empty() {
|
||||
let keep: HashSet<PathBuf> = index
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()))
|
||||
.collect();
|
||||
for entry in previous.entries() {
|
||||
let rel = entry.path(&previous);
|
||||
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
||||
|
||||
if keep.contains(&rel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = workdir.join(&rel);
|
||||
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("failed to remove {}", path.display()));
|
||||
}
|
||||
}
|
||||
impl WorktreeSnapshot {
|
||||
/// Creates a snapshot of the worktree contents and ref state.
|
||||
pub(crate) fn new(
|
||||
entries: Vec<PathBuf>,
|
||||
readme_path: Option<PathBuf>,
|
||||
readme: Option<Vec<u8>>,
|
||||
current_branch: Option<String>,
|
||||
head_commit: Option<FileCommit>,
|
||||
branches: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
readme_path,
|
||||
readme,
|
||||
current_branch,
|
||||
head_commit,
|
||||
branches,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
let mut options =
|
||||
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||
options.overwrite_existing = true;
|
||||
|
||||
let objects = repo.objects.clone().into_arc()?;
|
||||
let files = gix::progress::Discard;
|
||||
let bytes = gix::progress::Discard;
|
||||
|
||||
// Check out the index into the worktree.
|
||||
gix_worktree_state::checkout(
|
||||
&mut index,
|
||||
workdir,
|
||||
objects,
|
||||
&files,
|
||||
&bytes,
|
||||
&gix::interrupt::IS_INTERRUPTED,
|
||||
options,
|
||||
)?;
|
||||
|
||||
// Write the index to disk.
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point `HEAD` at `target` and record the switch in the reflog.
|
||||
fn move_head(
|
||||
repo: &gix::Repository,
|
||||
signature: gix::actor::SignatureRef<'_>,
|
||||
target: gix::refs::Target,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
// Update the reference, creating a reflog entry.
|
||||
repo.edit_references_as(
|
||||
[RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: message.into(),
|
||||
},
|
||||
expected: PreviousValue::Any,
|
||||
new: target,
|
||||
},
|
||||
name: head,
|
||||
deref: false,
|
||||
}],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the local branch `name`, HEAD stays attached to it.
|
||||
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let full = format!("refs/heads/{name}");
|
||||
|
||||
let branch = gix::refs::FullName::try_from(full.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
let mut reference = repo.find_reference(&full)?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the branch, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
gix::refs::Target::Symbolic(branch),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the branch's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
|
||||
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let full = format!("refs/tags/{name}");
|
||||
|
||||
let mut reference = repo.find_reference(&full)?;
|
||||
|
||||
let commit = reference.peel_to_id()?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the tag, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
gix::refs::Target::Object(commit.detach()),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the tag's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_dir = entry.file_type()?.is_dir();
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(root)?.to_path_buf();
|
||||
out.push((rel, is_dir));
|
||||
|
||||
if is_dir {
|
||||
collect_entries(root, &path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,12 +8,7 @@ publish.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
nostr-lmdb.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
nostr-lmdb.workspace = true
|
||||
|
||||
@@ -1,51 +1,47 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_gossip_memory::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use nostr_lmdb::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use nostr_memory::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::signer::UniversalSigner;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let database = NostrLmdb::open(db_path)
|
||||
.await
|
||||
.context("failed to open nostr database")?;
|
||||
Ok(with_database(signer, database))
|
||||
pub struct NostrBackend {
|
||||
pub client: Client,
|
||||
pub signer: UniversalSigner,
|
||||
}
|
||||
|
||||
/// In-memory database on wasm, LMDB is unavailable there.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
Ok(with_database(signer, MemoryDatabase::unbounded()))
|
||||
}
|
||||
impl NostrBackend {
|
||||
pub async fn open(db_path: impl AsRef<Path>) -> Result<Self> {
|
||||
let db_path = db_path.as_ref();
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let database = NostrLmdb::open(db_path)
|
||||
.await
|
||||
.context("failed to open nostr database")?;
|
||||
Ok(Self::with_database(signer, database))
|
||||
}
|
||||
|
||||
fn with_database<D>(signer: UniversalSigner, database: D) -> (Client, UniversalSigner)
|
||||
where
|
||||
D: IntoNostrDatabase,
|
||||
{
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
fn with_database<D>(signer: UniversalSigner, database: D) -> Self
|
||||
where
|
||||
D: IntoNostrDatabase,
|
||||
{
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
let client = ClientBuilder::default()
|
||||
.database(database)
|
||||
.authenticator(authenticator)
|
||||
.gossip(NostrGossipMemory::unbounded())
|
||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.verify_subscriptions(true)
|
||||
.ban_relay_on_mismatch(true)
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(600),
|
||||
})
|
||||
.build();
|
||||
let client = ClientBuilder::default()
|
||||
.database(database)
|
||||
.authenticator(authenticator)
|
||||
.gossip(NostrGossipMemory::unbounded())
|
||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.verify_subscriptions(true)
|
||||
.ban_relay_on_mismatch(true)
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(600),
|
||||
})
|
||||
.build();
|
||||
|
||||
(client, signer)
|
||||
Self { client, signer }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ mod backend;
|
||||
mod signer;
|
||||
mod update;
|
||||
|
||||
pub use backend::new_backend;
|
||||
pub use backend::NostrBackend;
|
||||
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
|
||||
pub use update::Update;
|
||||
|
||||
@@ -5,33 +5,9 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nostr_connect::client::AuthUrlHandler;
|
||||
use nostr_sdk::error::Error as SignerError;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||
|
||||
impl fmt::Display for UniversalSignerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for UniversalSignerError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSignerError {
|
||||
pub fn new<E>(err: E) -> Self
|
||||
where
|
||||
E: Error + Send + Sync + 'static,
|
||||
{
|
||||
UniversalSignerError(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||
@@ -50,7 +26,6 @@ impl UniversalSigner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the inner signer in-place. All clones see the new signer.
|
||||
pub fn swap_inner<T>(&self, new_signer: T)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
@@ -65,21 +40,21 @@ impl UniversalSigner {
|
||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -94,22 +69,22 @@ where
|
||||
{
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,11 +92,11 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,17 +104,17 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
@@ -150,7 +125,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
@@ -162,7 +137,7 @@ impl AsyncSignEvent for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncNip44 for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
@@ -183,7 +158,6 @@ impl AsyncNip44 for UniversalSigner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the NIP-46 auth URL in the default browser.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedAuthUrlHandler;
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A lightweight change notification for the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Update {
|
||||
pub kind: Kind,
|
||||
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
||||
pub coordinate: Option<Coordinate>,
|
||||
pub author: PublicKey,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
/// Build an update from a received event.
|
||||
pub fn from_event(event: &Event) -> Self {
|
||||
let coordinate = event.tags.coordinates().nth(0);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ nostr-connect.workspace = true
|
||||
|
||||
bitcoin_hashes = "1"
|
||||
|
||||
gix.workspace = true
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
+589
-1454
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::client::SyncSummary;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::Filters;
|
||||
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
pub(crate) async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
|
||||
for url in BOOTSTRAP_RELAYS {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
for url in INDEXER_RELAYS {
|
||||
client
|
||||
.add_relay(url)
|
||||
.capabilities(RelayCapabilities::DISCOVERY)
|
||||
.and_connect()
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribe_bootstrap_only(
|
||||
client: &Client,
|
||||
filters: Vec<Filter>,
|
||||
) -> Result<(), Error> {
|
||||
ensure_bootstrap_relays(client).await?;
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(10)));
|
||||
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.iter()
|
||||
.map(|relay| (*relay, filters.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_bootstrap_only(
|
||||
client: &Client,
|
||||
filter: Filter,
|
||||
opts: SyncOptions,
|
||||
) -> Result<SyncSummary, Error> {
|
||||
ensure_bootstrap_relays(client).await?;
|
||||
|
||||
let output = client
|
||||
.sync(filter)
|
||||
.with(BOOTSTRAP_RELAYS)
|
||||
.opts(opts)
|
||||
.await?;
|
||||
|
||||
Ok(output.value)
|
||||
}
|
||||
|
||||
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "g")
|
||||
.filter_map(|tag| tag.content())
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn user_grasp_list_servers(
|
||||
client: &Client,
|
||||
user: PublicKey,
|
||||
) -> Result<Vec<RelayUrl>, Error> {
|
||||
let events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filters::grasp_list(user).limit(1))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
Ok(events.first().map(grasp_list_servers).unwrap_or_default())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,41 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gpui::{App, Global};
|
||||
use signed_git::GitCache;
|
||||
use anyhow::Result;
|
||||
use nostr::prelude::Url;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_git::{GitCache, Repo};
|
||||
|
||||
struct GlobalGitStore(GitCache);
|
||||
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
|
||||
|
||||
impl Global for GlobalGitStore {}
|
||||
pub struct Mirrors;
|
||||
|
||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStore(GitCache);
|
||||
|
||||
impl GitStore {
|
||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
store
|
||||
impl Mirrors {
|
||||
pub fn install(root: impl Into<PathBuf>) {
|
||||
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
|
||||
log::warn!("git cache root is already set, keeping the first one");
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-wide clone cache.
|
||||
pub fn global(cx: &App) -> Self {
|
||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||
fn cache() -> &'static GitCache {
|
||||
GIT_CACHE
|
||||
.get()
|
||||
.expect("git cache is initialized by signed_state::init")
|
||||
}
|
||||
|
||||
fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self(GitCache::new(root.into()))
|
||||
pub(crate) fn root() -> PathBuf {
|
||||
Self::cache().root().to_path_buf()
|
||||
}
|
||||
|
||||
/// Underlying clone cache.
|
||||
pub fn cache(&self) -> &GitCache {
|
||||
&self.0
|
||||
pub fn path(addr: &RepoAddr) -> PathBuf {
|
||||
Self::cache().repo_path(addr)
|
||||
}
|
||||
|
||||
pub fn open(addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||
Self::cache().open(addr)
|
||||
}
|
||||
|
||||
pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repo> {
|
||||
Self::cache().ensure_clone(addr, clone_urls)
|
||||
}
|
||||
}
|
||||
|
||||
+138
-171
@@ -3,80 +3,38 @@ use std::collections::{HashMap, HashSet};
|
||||
use anyhow::Error;
|
||||
use gpui::{AppContext, Context, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
||||
use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// The user's persisted inbox read state.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
/// Set once the stored state has been read for the current user.
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
/// The current read/archive cutoffs.
|
||||
pub fn state(&self) -> &InboxReadState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Whether the stored state has been read for the current user.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.loaded
|
||||
}
|
||||
|
||||
/// Mark the events of one notification group read, then bound the id sets.
|
||||
pub fn mark_read(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
for event in group {
|
||||
self.state.mark_read(event);
|
||||
}
|
||||
self.state.advance_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Archive one notification group. Archived events are always read too.
|
||||
pub fn mark_archived(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
for event in group {
|
||||
self.state.mark_archived(event);
|
||||
self.state.mark_read(event);
|
||||
}
|
||||
|
||||
let now = Timestamp::now();
|
||||
self.state.advance_archived(all, me, now);
|
||||
self.state.advance_read(all, me, now);
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||
self.state.mark_all_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Load the stored state for current user.
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
||||
let work = cx.background_spawn(async move { Self::load_state(&client, me).await });
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let loaded = work.await;
|
||||
@@ -101,24 +59,27 @@ impl Inbox {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Clear the state of the signed-out user.
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sign the state with a random key and store it locally.
|
||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
let backend = Backend::global(cx);
|
||||
let (me, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(me) = me else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let state = self.state.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
if let Err(error) = save_state(&client, me, &state).await {
|
||||
if let Err(error) = Self::save_state(&client, me, &state).await {
|
||||
log::warn!("failed to save inbox state: {error}");
|
||||
}
|
||||
Ok(())
|
||||
@@ -126,138 +87,144 @@ impl Inbox {
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the inbox home screen's threads for `me` from the local database.
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
pub async fn query(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||
let deletion_events = client.database().query(Filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||
let (notification_events, mut by_id) =
|
||||
Self::fetch_notifications(client, me, &deletions).await?;
|
||||
|
||||
let mut activity = Vec::new();
|
||||
for event in client
|
||||
.database()
|
||||
.query(filters::authored_activity(me))
|
||||
.await?
|
||||
{
|
||||
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
||||
continue;
|
||||
}
|
||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||
activity.push(event);
|
||||
}
|
||||
|
||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
||||
by_id.get(&id).cloned()
|
||||
});
|
||||
|
||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
|
||||
Ok((items, unread_count))
|
||||
}
|
||||
|
||||
/// `d` tag identifying the inbox state event of `me`.
|
||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
/// Newest stored state for `me`.
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(inbox_state_d_tag(me));
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match serde_json::from_str(&event.content) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign with a random key and store locally.
|
||||
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||
.finalize(&Keys::generate())?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notification events and a lookup of every ancestor they reference.
|
||||
async fn fetch_notifications(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
deletions: &Deletions,
|
||||
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||
let mut notifications: Vec<Event> = Vec::new();
|
||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||
|
||||
for filter in filters::notifications(me) {
|
||||
for event in client.database().query(filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if by_id.insert(event.id, event.clone()).is_none() {
|
||||
notifications.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
|
||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||
|
||||
loop {
|
||||
// Keep only ids not walked yet, and remember them.
|
||||
pending.retain(|id| seen.insert(*id));
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ancestors = client
|
||||
let mut activity = Vec::new();
|
||||
for event in client
|
||||
.database()
|
||||
.query(Filter::new().ids(pending.iter().copied()))
|
||||
.await?;
|
||||
|
||||
let mut next = Vec::new();
|
||||
|
||||
for event in ancestors {
|
||||
if deletions.is_deleted(&event) {
|
||||
.query(Filters::authored_activity(me))
|
||||
.await?
|
||||
{
|
||||
if deletions.is_deleted(&event) || !event.is_git_activity() {
|
||||
continue;
|
||||
}
|
||||
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
|
||||
by_id.entry(event.id).or_insert(event);
|
||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||
activity.push(event);
|
||||
}
|
||||
|
||||
pending = next;
|
||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
||||
by_id.get(&id).cloned()
|
||||
});
|
||||
|
||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
|
||||
Ok((items, unread_count))
|
||||
}
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
Ok((notifications, by_id))
|
||||
}
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(Self::inbox_state_d_tag(me));
|
||||
|
||||
/// Event ids referenced by `event` through its `e` and `E` tags.
|
||||
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||
event.tags.iter().filter_map(|tag| {
|
||||
if tag.kind() != "e" && tag.kind() != "E" {
|
||||
return None;
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match serde_json::from_str(&event.content) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
// Signed with a random key: the state is local-only, authorship does not
|
||||
// matter and no key material needs to be kept.
|
||||
async fn save_state(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(), Error> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(Self::inbox_state_d_tag(me))])
|
||||
.finalize(&Keys::generate())?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_notifications(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
deletions: &Deletions,
|
||||
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||
let mut notifications: Vec<Event> = Vec::new();
|
||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||
|
||||
for filter in Filters::notifications(me) {
|
||||
for event in client.database().query(filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if by_id.insert(event.id, event.clone()).is_none() {
|
||||
notifications.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending: Vec<EventId> = notifications
|
||||
.iter()
|
||||
.flat_map(Self::event_references)
|
||||
.collect();
|
||||
|
||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||
|
||||
loop {
|
||||
pending.retain(|id| seen.insert(*id));
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ancestors = client
|
||||
.database()
|
||||
.query(Filter::new().ids(pending.iter().copied()))
|
||||
.await?;
|
||||
|
||||
let mut next = Vec::new();
|
||||
|
||||
for event in ancestors {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
next.extend(Self::event_references(&event).filter(|id| !seen.contains(id)));
|
||||
by_id.entry(event.id).or_insert(event);
|
||||
}
|
||||
|
||||
pending = next;
|
||||
}
|
||||
|
||||
Ok((notifications, by_id))
|
||||
}
|
||||
|
||||
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||
event.tags.iter().filter_map(|tag| {
|
||||
if tag.kind() != "e" && tag.kind() != "E" {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
mod backend;
|
||||
mod bootstrap;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod inbox;
|
||||
mod profile;
|
||||
mod local_repos;
|
||||
mod patch_series;
|
||||
mod push;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
mod repos;
|
||||
mod state;
|
||||
mod sync_status;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use bootstrap::{sync_bootstrap_only, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore};
|
||||
pub use git_store::Mirrors;
|
||||
use gpui::{App, AppContext};
|
||||
pub use inbox::{Inbox, query_inbox};
|
||||
pub use inbox::Inbox;
|
||||
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
|
||||
pub use nostr_sdk::prelude::Timestamp;
|
||||
pub use profile::{Profile, ProfileStore};
|
||||
pub use push::{GraspServer, PushOutcome};
|
||||
pub use refresh::{RefreshGate, RefreshRequest};
|
||||
pub use repo::RepoStore;
|
||||
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
pub use repos::RepoListStore;
|
||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||
use signed_nostr::NostrBackend;
|
||||
pub use state::{GitPushMode, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState};
|
||||
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
repos_root: impl Into<PathBuf>,
|
||||
@@ -32,29 +40,17 @@ pub fn init(
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let (client, signer) = cx.foreground_executor().block_on(async move {
|
||||
let path = db_path.as_ref().to_path_buf();
|
||||
new_backend(path)
|
||||
.await
|
||||
.expect("failed to initialize nostr backend")
|
||||
});
|
||||
let backend = cx
|
||||
.foreground_executor()
|
||||
.block_on(async move { NostrBackend::open(db_path.as_ref()).await });
|
||||
let backend = backend.expect("failed to initialize nostr backend");
|
||||
let (client, signer) = (backend.client, backend.signer);
|
||||
|
||||
Mirrors::install(repos_root);
|
||||
|
||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
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);
|
||||
}
|
||||
|
||||
/// Initialize the backend with an in-memory database on wasm.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) {
|
||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||
SyncStatusStore::set_global(cx.new(SyncStatusStore::new), cx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
||||
|
||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||
|
||||
impl Global for GlobalLocalReposStore {}
|
||||
|
||||
pub struct LocalReposStore {
|
||||
pub roots: Arc<Vec<PathBuf>>,
|
||||
pub repos: Arc<Vec<LocalRepo>>,
|
||||
pub scanning: bool,
|
||||
scan_dirty: bool,
|
||||
}
|
||||
|
||||
impl LocalReposStore {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalLocalReposStore(entity));
|
||||
}
|
||||
|
||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
|
||||
log::warn!("local repos store dropped before initial scan could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
roots: Arc::new(roots),
|
||||
repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
scan_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
.iter()
|
||||
.filter(|repo| repo.path.as_path() != path)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||
if self.scanning {
|
||||
self.scan_dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.scanning = true;
|
||||
cx.notify();
|
||||
|
||||
let roots = self.roots.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let mut repos = Vec::new();
|
||||
for root in roots.iter() {
|
||||
repos.extend(find_git_repos(root));
|
||||
}
|
||||
repos.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
repos.dedup_by(|a, b| a.path == b.path);
|
||||
repos
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let repos = work.await;
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.repos = Arc::new(repos);
|
||||
this.scanning = false;
|
||||
cx.notify();
|
||||
|
||||
let dirty = this.scan_dirty;
|
||||
this.scan_dirty = false;
|
||||
dirty
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
|
||||
let binding = repo.nip34.as_ref()?;
|
||||
let owner = binding.owner?;
|
||||
let identifier = binding.identifier.as_deref()?;
|
||||
|
||||
Some(RepoAddr::new(owner, identifier))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedLocalRepo {
|
||||
pub path: PathBuf,
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
pub announcement: Option<Announcement>,
|
||||
}
|
||||
|
||||
impl ResolvedLocalRepo {
|
||||
pub fn name(&self) -> SharedString {
|
||||
self.path
|
||||
.file_name()
|
||||
.map(|name| SharedString::from(name.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| SharedString::from("Untitled"))
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalReposStore {
|
||||
pub fn resolve(
|
||||
repos: &[LocalRepo],
|
||||
known: &[Announcement],
|
||||
own: &[Announcement],
|
||||
) -> Vec<ResolvedLocalRepo> {
|
||||
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
|
||||
|
||||
repos
|
||||
.iter()
|
||||
.filter_map(|repo| {
|
||||
let addr = local_repo_addr(repo);
|
||||
|
||||
if let Some(addr) = &addr
|
||||
&& shown.contains(addr)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let announcement = addr
|
||||
.as_ref()
|
||||
.and_then(|addr| {
|
||||
known
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == *addr)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
Some(ResolvedLocalRepo {
|
||||
path: repo.path.clone(),
|
||||
nip34: repo.nip34.clone(),
|
||||
announcement,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr::prelude::*;
|
||||
use signed_git::{GraspSignals, Nip34Kind};
|
||||
|
||||
use super::*;
|
||||
|
||||
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const OTHER_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
|
||||
fn announcement(secret: &str, id: &str) -> Announcement {
|
||||
let keys = Keys::new(SecretKey::from_hex(secret).expect("secret"));
|
||||
let tag = Tag::parse(vec!["d", id]).expect("tag");
|
||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![tag])
|
||||
.finalize(&keys)
|
||||
.expect("signed");
|
||||
|
||||
Announcement::from_event(&event).expect("parsed")
|
||||
}
|
||||
|
||||
fn owner(secret: &str) -> PublicKey {
|
||||
Keys::new(SecretKey::from_hex(secret).expect("secret")).public_key()
|
||||
}
|
||||
|
||||
fn bound(secret: &str, id: &str) -> LocalRepo {
|
||||
let binding = Nip34Binding {
|
||||
kind: Nip34Kind::Initialized,
|
||||
signals: GraspSignals {
|
||||
nip34_json: true,
|
||||
..Default::default()
|
||||
},
|
||||
owner: Some(owner(secret)),
|
||||
identifier: Some(id.to_owned()),
|
||||
grasp_urls: Vec::new(),
|
||||
};
|
||||
|
||||
LocalRepo {
|
||||
path: PathBuf::from(id),
|
||||
nip34: Some(binding),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_own_announcement_is_dropped() {
|
||||
let own = announcement(KEY, "mine");
|
||||
let repo = bound(KEY, "mine");
|
||||
|
||||
let own = std::slice::from_ref(&own);
|
||||
assert!(LocalReposStore::resolve(&[repo], own, own).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_owners_announcement_is_linked_and_kept() {
|
||||
let known = announcement(OTHER_KEY, "theirs");
|
||||
let repo = bound(OTHER_KEY, "theirs");
|
||||
|
||||
let resolved = LocalReposStore::resolve(&[repo], std::slice::from_ref(&known), &[]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use signed_git::PatchParser;
|
||||
|
||||
/// NIP-34 suggests patches when each event is under 60kb.
|
||||
pub(crate) const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||
|
||||
pub(crate) struct PatchSeries {
|
||||
pub(crate) parts: Vec<String>,
|
||||
}
|
||||
|
||||
impl PatchSeries {
|
||||
pub(crate) fn parse(patch: &str) -> Self {
|
||||
Self {
|
||||
parts: PatchParser::split_patch_series(patch)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the length of the first part that exceeds the NIP-34 recommended size.
|
||||
pub(crate) fn oversized_length(&self) -> Option<usize> {
|
||||
self.parts
|
||||
.iter()
|
||||
.map(String::len)
|
||||
.find(|length| *length > MAX_PATCH_EVENT_BYTES)
|
||||
}
|
||||
|
||||
/// The last commit of the series, which is the tip of the branch.
|
||||
pub(crate) fn tip_commit(&self) -> Option<Sha1Hash> {
|
||||
self.parts
|
||||
.last()
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
|
||||
}
|
||||
|
||||
pub(crate) fn commit_of(&self, index: usize) -> Option<&str> {
|
||||
self.parts
|
||||
.get(index)
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
.filter(|hex| hex.len() == 40)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `From <commit>` header on the first line.
|
||||
fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
let line = patch.lines().next()?;
|
||||
let hex = line.strip_prefix("From ")?;
|
||||
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::{Receiver, Sender};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
|
||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// Display name, falling back to `name`, then a shortened npub.
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name.trim().to_owned());
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata.name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key, 4))
|
||||
}
|
||||
|
||||
/// Avatar URL, if set.
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| SharedString::from(p.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait for more requests before firing a batched sync.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Global profile cache.
|
||||
///
|
||||
/// Profiles are fetched in batches and kept as plain data.
|
||||
pub struct ProfileStore {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
/// Public keys requested this session, main thread only.
|
||||
seen: RefCell<HashSet<PublicKey>>,
|
||||
/// Sender for queuing fetch requests, batched by a background task.
|
||||
sender: Sender<PublicKey>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||
|
||||
impl Global for GlobalProfileStore {}
|
||||
|
||||
impl ProfileStore {
|
||||
/// Retrieve the global profile store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalProfileStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalProfileStore(entity));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
for update in updates
|
||||
.iter()
|
||||
.filter(|update| update.kind == Kind::Metadata)
|
||||
{
|
||||
this.apply_author(update.author, cx);
|
||||
}
|
||||
}
|
||||
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
this.profiles
|
||||
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// 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 entity = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |_this, cx| {
|
||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||
})
|
||||
.detach();
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
||||
log::warn!("profile store dropped before initial load could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
profiles: HashMap::new(),
|
||||
seen: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a profile.
|
||||
///
|
||||
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
|
||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
|
||||
if self.seen.borrow_mut().insert(public_key)
|
||||
&& let Err(e) = self.sender.send(public_key)
|
||||
{
|
||||
log::warn!("failed to queue profile fetch: {e}");
|
||||
}
|
||||
|
||||
Profile::new(public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Load recently seen profiles from the local database.
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread.
|
||||
// Only plain profiles cross back.
|
||||
let profiles: Vec<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
Profile::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
for profile in profiles {
|
||||
this.profiles.insert(profile.public_key(), profile);
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of an author from the local database.
|
||||
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread.
|
||||
// Only the profile crosses back.
|
||||
let profile = events
|
||||
.into_iter()
|
||||
.max_by_key(|e| e.created_at)
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Profile::new(event.pubkey, metadata)
|
||||
});
|
||||
|
||||
Ok::<_, Error>(profile)
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profile = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some(profile) = profile {
|
||||
this.profiles.insert(profile.public_key(), profile);
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of every requested author from the local database.
|
||||
///
|
||||
/// Used after a sync, which produces no NostrUpdate events.
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
|
||||
if authors.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Pick the latest metadata per author off the main thread.
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((ts, _)) if *ts >= event.created_at => {}
|
||||
_ => {
|
||||
latest.insert(
|
||||
event.pubkey,
|
||||
(
|
||||
event.created_at,
|
||||
Metadata::from_json(&event.content).unwrap_or_default(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let profiles: Vec<Profile> = latest
|
||||
.into_iter()
|
||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
||||
.collect();
|
||||
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
for profile in profiles {
|
||||
this.profiles.insert(profile.public_key(), profile);
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||
///
|
||||
/// 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,
|
||||
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_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// Collect everything that arrives within the debounce window.
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
loop {
|
||||
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)
|
||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||
|
||||
// Negentropy-sync with the bootstrap relays.
|
||||
// Synced events are written to the database directly, no NostrUpdate.
|
||||
// Re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||
}
|
||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use nostr::prelude::Url;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// `ws://` grasp servers, like ngit, use `http(s)://<host>`.
|
||||
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
let parsed = Url::parse(relay.as_str()).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let port = parsed
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
let scheme = if relay.scheme().is_secure() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
Some(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
||||
let base = grasp_base_url(relay)?;
|
||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||
}
|
||||
|
||||
/// GRASP-06 contributor namespace URL of a pull request tip.
|
||||
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||
}
|
||||
|
||||
/// The author's GRASP-06 `/prs/` URLs come first.
|
||||
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
for url in prs_urls.into_iter().chain(base_clone_urls) {
|
||||
if seen.insert(url.to_string()) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraspServer {
|
||||
relay: RelayUrl,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
impl GraspServer {
|
||||
pub(crate) fn ok(relay: RelayUrl) -> Self {
|
||||
Self {
|
||||
relay,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn failed(relay: &RelayUrl, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
relay: relay.to_owned(),
|
||||
reason: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn relay(&self) -> &RelayUrl {
|
||||
&self.relay
|
||||
}
|
||||
|
||||
pub fn reason(&self) -> Option<&str> {
|
||||
self.reason.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PushOutcome {
|
||||
pub servers: Vec<GraspServer>,
|
||||
/// The newest state event a grasp relay accepted for this push.
|
||||
pub state_event: Option<Event>,
|
||||
}
|
||||
|
||||
impl PushOutcome {
|
||||
pub fn accepted(&self) -> usize {
|
||||
self.servers
|
||||
.iter()
|
||||
.filter(|server| server.reason.is_none())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn failing(&self) -> impl Iterator<Item = &GraspServer> {
|
||||
self.servers.iter().filter(|server| server.reason.is_some())
|
||||
}
|
||||
|
||||
pub fn failure_summary(&self) -> String {
|
||||
self.failing()
|
||||
.map(|server| {
|
||||
let reason =
|
||||
utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error"));
|
||||
format!("{}: {reason}", server.relay)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// `None` when every server accepted the push or nothing was pushed.
|
||||
pub fn partial_warning(&self) -> Option<String> {
|
||||
let accepted = self.accepted();
|
||||
if self.servers.is_empty() || accepted == self.servers.len() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"Pushed to {accepted} of {} grasp servers: {}. Republish to sync.",
|
||||
self.servers.len(),
|
||||
self.failure_summary()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,39 @@
|
||||
/// Refresh coalescing shared by the event stores.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RefreshGate {
|
||||
/// A run is in flight.
|
||||
running: bool,
|
||||
/// A request arrived while a run was in flight.
|
||||
dirty: bool,
|
||||
/// The debounce timer is pending.
|
||||
debouncing: bool,
|
||||
}
|
||||
|
||||
/// What a refresh request decided.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshRequest {
|
||||
/// No run or timer covers the request, start the debounce timer.
|
||||
Schedule,
|
||||
/// A run or pending timer already covers the request.
|
||||
Fold,
|
||||
}
|
||||
|
||||
impl RefreshGate {
|
||||
/// Whether a run is in flight.
|
||||
pub fn running(&self) -> bool {
|
||||
self.running
|
||||
}
|
||||
|
||||
/// Whether the debounce timer is pending.
|
||||
pub fn debouncing(&self) -> bool {
|
||||
self.debouncing
|
||||
}
|
||||
|
||||
/// A new refresh request arrived.
|
||||
///
|
||||
/// Folded into a follow-up run while one is in flight, dropped while the
|
||||
/// debounce timer is pending, otherwise starts the timer.
|
||||
pub fn request(&mut self) -> RefreshRequest {
|
||||
if self.running {
|
||||
self.dirty = true;
|
||||
RefreshRequest::Fold
|
||||
} else if self.debouncing {
|
||||
RefreshRequest::Fold
|
||||
} else {
|
||||
self.debouncing = true;
|
||||
RefreshRequest::Schedule
|
||||
}
|
||||
}
|
||||
|
||||
/// The debounce timer fired and the run starts now.
|
||||
pub fn begin(&mut self) {
|
||||
self.debouncing = false;
|
||||
self.running = true;
|
||||
}
|
||||
|
||||
/// The run ended. Whether a request arrived while it ran.
|
||||
pub fn finish(&mut self) -> bool {
|
||||
self.running = false;
|
||||
std::mem::take(&mut self.dirty)
|
||||
}
|
||||
|
||||
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
|
||||
// Pending follow-up requests survive an abandoned run.
|
||||
pub fn abort(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
+548
-703
File diff suppressed because it is too large
Load Diff
@@ -1,127 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
use signed_git::find_git_repos;
|
||||
use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
|
||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||
|
||||
impl Global for GlobalLocalReposStore {}
|
||||
|
||||
/// Store of the git repositories discovered under a set of scan paths.
|
||||
pub struct LocalReposStore {
|
||||
/// The directories being scanned.
|
||||
pub roots: Arc<Vec<PathBuf>>,
|
||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||
pub repos: Arc<Vec<PathBuf>>,
|
||||
/// A scan is currently running.
|
||||
pub scanning: bool,
|
||||
/// A scan was requested while one was already running.
|
||||
scan_dirty: bool,
|
||||
}
|
||||
|
||||
impl LocalReposStore {
|
||||
/// Retrieve the global local-repositories store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalLocalReposStore(entity));
|
||||
}
|
||||
|
||||
/// Create a store scanning `roots` right away.
|
||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
|
||||
log::warn!("local repos store dropped before initial scan could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
roots: Arc::new(roots),
|
||||
repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
scan_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
.iter()
|
||||
.filter(|repo| repo.as_path() != path)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Re-run the scan.
|
||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||
if self.scanning {
|
||||
self.scan_dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.scanning = true;
|
||||
cx.notify();
|
||||
|
||||
let roots = self.roots.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let mut repos = Vec::new();
|
||||
for root in roots.iter() {
|
||||
repos.extend(find_git_repos(root));
|
||||
}
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
repos
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let repos = work.await;
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.repos = Arc::new(repos);
|
||||
this.scanning = false;
|
||||
cx.notify();
|
||||
|
||||
let dirty = this.scan_dirty;
|
||||
this.scan_dirty = false;
|
||||
dirty
|
||||
})?;
|
||||
|
||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
///
|
||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||
|
||||
@@ -129,44 +17,29 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
|
||||
impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RepoActivityCounts {
|
||||
/// Root `30611` issue events addressed to the repository.
|
||||
pub issues: u32,
|
||||
/// Root `3063` pull request events addressed to the repository.
|
||||
///
|
||||
/// PR updates are not new PRs and do not count.
|
||||
pub pull_requests: u32,
|
||||
/// `1617` patch events addressed to the repository.
|
||||
pub commits: u32,
|
||||
}
|
||||
|
||||
impl RepoActivityCounts {
|
||||
/// Total issues, pull requests and commits, the popularity ranking key.
|
||||
pub fn score(self) -> u32 {
|
||||
self.issues + self.pull_requests + self.commits
|
||||
}
|
||||
}
|
||||
|
||||
/// Store listing the discovered repository announcements, newest first.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository.
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues, pull requests and commits per repository.
|
||||
///
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
state_synced_repos: HashSet<RepoAddr>,
|
||||
refresh: RefreshGate,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global repository list store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
@@ -175,39 +48,17 @@ impl RepoListStore {
|
||||
cx.set_global(GlobalRepoListStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store listing all announcements.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||
// Deletions may target anything we list, always refresh.
|
||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||
true
|
||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||
// Activity events are addressed to repos via `a` tags.
|
||||
// Their author is not the repo owner, always refresh.
|
||||
true
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
let is_repo_state = update.kind == Kind::RepoState;
|
||||
is_announcement || is_repo_state
|
||||
}
|
||||
}),
|
||||
BackendEvent::Published(event) => {
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||
|
||||
// Locally published deletions are already in the local database.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
announcement || deletion
|
||||
BackendEvent::SignerChanged => {
|
||||
this.state_synced_repos.clear();
|
||||
true
|
||||
}
|
||||
// Only a completed sync refreshes the list.
|
||||
// Progress ticks would re-scan the whole database several times
|
||||
// per sync to reveal entries incrementally.
|
||||
BackendEvent::RepoUpdates(_) => true,
|
||||
BackendEvent::Synced => true,
|
||||
_ => false,
|
||||
};
|
||||
@@ -217,29 +68,24 @@ impl RepoListStore {
|
||||
}
|
||||
});
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
let result = weak.update(cx, |this, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.subscribe_remote(cx);
|
||||
// Query the local database right away.
|
||||
// The list never waits for the relay syncs started above to finish.
|
||||
this.refresh_initial(cx);
|
||||
});
|
||||
if let Err(error) = result {
|
||||
log::warn!("repo list store dropped before bootstrap could run: {error}");
|
||||
}
|
||||
this.refresh(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
state_synced_repos: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// The announcements of `user`, newest first.
|
||||
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
||||
self.announcements
|
||||
.iter()
|
||||
@@ -248,44 +94,55 @@ impl RepoListStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
backend.sync_bootstraps(
|
||||
vec![
|
||||
Filters::all_announcements(),
|
||||
Filters::all_states(),
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
Filters::deletions(),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load.
|
||||
///
|
||||
/// Query the local database immediately, no debounce.
|
||||
/// Stored announcements appear as soon as the app opens.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let Some(me) = backend.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pending: Vec<(RepoAddr, Vec<RelayUrl>)> = self
|
||||
.announcements
|
||||
.iter()
|
||||
.filter(|announcement| announcement.owner == me && !announcement.relays.is_empty())
|
||||
.map(|announcement| (announcement.addr(), announcement.relays.clone()))
|
||||
.filter(|(addr, _)| !self.state_synced_repos.contains(addr))
|
||||
.collect();
|
||||
|
||||
for (addr, relays) in pending {
|
||||
self.state_synced_repos.insert(addr.clone());
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.connect_repo_relays(relays, vec![addr.state_filter()], cx);
|
||||
});
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
// Runs immediately: the backend pump already batches the relay events
|
||||
// that trigger a refresh, so no per-store debounce is needed.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
})
|
||||
.detach();
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
@@ -293,14 +150,14 @@ impl RepoListStore {
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = filters::all_announcements();
|
||||
let filter = Filters::all_announcements();
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletion_events = client.database().query(Filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
// Dedup and sort off the main thread.
|
||||
// Only the final list crosses back into the entity.
|
||||
// Dedup and sort off the main thread; only the final list crosses
|
||||
// back into the entity.
|
||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
@@ -325,9 +182,6 @@ impl RepoListStore {
|
||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||
|
||||
// Last activity per repository.
|
||||
// State updates count, and all NIP-34 activity events.
|
||||
// The activity events are patches, PRs, issues and statuses.
|
||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
@@ -341,28 +195,27 @@ impl RepoListStore {
|
||||
let Some(id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
let addr = repo_addr(event.pubkey, id);
|
||||
let addr = RepoAddr::new(event.pubkey, id);
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
|
||||
// Bound the activity query to a recent window.
|
||||
// Older repos fall back to their announcement or state timestamps.
|
||||
let activity_filter = Filter::new()
|
||||
.kinds(filters::ACTIVITY_KINDS)
|
||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||
|
||||
for event in client.database().query(activity_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
if addr.kind != Kind::GitRepoAnnouncement {
|
||||
for coordinate in event.tags.coordinates() {
|
||||
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
let addr = RepoAddr::from(coordinate.clone());
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
@@ -370,23 +223,23 @@ impl RepoListStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Popularity counts per repository, issues, pull requests and patches.
|
||||
// Unbounded, unlike the windowed activity query above, so totals are exact.
|
||||
// Unbounded, unlike the windowed activity query above, so totals
|
||||
// are exact.
|
||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
|
||||
for event in client.database().query(count_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||
for coordinate in event.tags.coordinates() {
|
||||
if coordinate.kind != Kind::GitRepoAnnouncement
|
||||
|| !last_activity.contains_key(&RepoAddr::from(coordinate.clone()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entry = counts.entry(addr).or_default();
|
||||
let entry = counts.entry(RepoAddr::from(coordinate)).or_default();
|
||||
match event.kind {
|
||||
Kind::GitIssue => entry.issues += 1,
|
||||
Kind::GitPullRequest => entry.pull_requests += 1,
|
||||
@@ -402,7 +255,6 @@ impl RepoListStore {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity, counts) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient, keep the last list.
|
||||
Err(_) => {
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refresh.abort();
|
||||
@@ -414,13 +266,12 @@ impl RepoListStore {
|
||||
this.announcements = Arc::new(announcements);
|
||||
this.last_activity = Arc::new(last_activity);
|
||||
this.counts = Arc::new(counts);
|
||||
this.sync_own_repo_states(cx);
|
||||
cx.notify();
|
||||
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running.
|
||||
// They are coalesced into one follow-up refresh.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use gpui::{AppContext, AsyncApp};
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::Announcement;
|
||||
use signed_git::Repo;
|
||||
|
||||
use crate::push::{GraspServer, PushOutcome, grasp_base_url};
|
||||
|
||||
/// A git ref with the commit it points at.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitRef {
|
||||
/// Full ref name, like `refs/heads/main` or `refs/tags/v1.0`.
|
||||
pub name: String,
|
||||
pub commit: Sha1Hash,
|
||||
}
|
||||
|
||||
impl GitRef {
|
||||
pub fn new(name: impl Into<String>, commit: &str) -> Result<Self, Error> {
|
||||
let commit = Sha1Hash::from_str(commit).map_err(|_| anyhow!("invalid commit: {commit}"))?;
|
||||
|
||||
Ok(Self {
|
||||
name: name.into(),
|
||||
commit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How much to push to the grasp servers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GitPushMode {
|
||||
/// Mirror all branches and tags.
|
||||
#[default]
|
||||
All,
|
||||
/// Push `refs/heads/main` only (repo creation).
|
||||
Main,
|
||||
}
|
||||
|
||||
/// How to handle server-only refs when pushing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GitPushPolicy {
|
||||
/// Reconcile the local refs with the grasp servers,
|
||||
/// fetching missing refs before pushing.
|
||||
#[default]
|
||||
Reconcile,
|
||||
/// Declare the local refs only and delete server-only refs while pushing.
|
||||
ResetRemote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SyncState {
|
||||
pub refs: Vec<GitRef>,
|
||||
pub head: Option<String>,
|
||||
/// Local repository to push from; `None` for states parsed from events.
|
||||
pub path: Option<PathBuf>,
|
||||
pub mode: GitPushMode,
|
||||
pub policy: GitPushPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PushStep {
|
||||
AnnounceRepository,
|
||||
/// Publish the state event to one grasp server.
|
||||
StageStateEvent(RelayUrl),
|
||||
/// Git push to one grasp server.
|
||||
PushObjects(RelayUrl),
|
||||
/// Fan the state event out to the relays.
|
||||
BroadcastStateEvent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PushStepStatus {
|
||||
Running(PushStep),
|
||||
Done(PushStep),
|
||||
Failed(PushStep, String),
|
||||
}
|
||||
|
||||
impl PushStep {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::AnnounceRepository => "Announce repository".to_owned(),
|
||||
Self::StageStateEvent(relay) => format!("Publish state event -> {relay}"),
|
||||
Self::BroadcastStateEvent => "Publish state event to relays".to_owned(),
|
||||
Self::PushObjects(relay) => format!(
|
||||
"Push objects -> {}",
|
||||
grasp_base_url(relay).unwrap_or_else(|| relay.to_string())
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PushStepStatus {
|
||||
pub fn step(&self) -> &PushStep {
|
||||
match self {
|
||||
Self::Running(step) | Self::Done(step) | Self::Failed(step, _) => step,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
pub fn build(id: &str, refs: &[GitRef], head: Option<&str>) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
|
||||
for git_ref in refs {
|
||||
let commit = git_ref.commit.to_string();
|
||||
tags.push(Tag::parse([git_ref.name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||
}
|
||||
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
}
|
||||
|
||||
pub fn parse(event: &Event) -> Self {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push(GitRef {
|
||||
name: format!("refs/heads/{branch}"),
|
||||
commit,
|
||||
});
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push(GitRef {
|
||||
name: format!("refs/tags/{name}"),
|
||||
commit,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
refs,
|
||||
head,
|
||||
path: None,
|
||||
mode: GitPushMode::default(),
|
||||
policy: GitPushPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the refs and head of a local repository, ready to push.
|
||||
pub fn from_repo(path: &Path) -> Result<Self, Error> {
|
||||
let repo = Repo::open(path)?;
|
||||
let state = repo.ref_state()?;
|
||||
|
||||
let refs = state
|
||||
.refs
|
||||
.iter()
|
||||
.map(|(name, commit)| GitRef::new(name, commit))
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
|
||||
Ok(Self {
|
||||
refs,
|
||||
head: state.head,
|
||||
path: Some(path.to_path_buf()),
|
||||
mode: GitPushMode::default(),
|
||||
policy: GitPushPolicy::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_mode(mut self, mode: GitPushMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_policy(mut self, policy: GitPushPolicy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reconciles the local repository with the grasp servers, fetching missing refs.
|
||||
pub async fn reconcile(
|
||||
&mut self,
|
||||
announcement: &Announcement,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
if self.policy == GitPushPolicy::ResetRemote || self.refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(path) = self.path.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let local: Vec<(String, String)> = self
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||
.collect();
|
||||
|
||||
let urls: Vec<String> = announcement
|
||||
.clone
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect();
|
||||
|
||||
let snapshot_path = path.clone();
|
||||
|
||||
let missing = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
|
||||
// Refs the servers advertise that the local repository lacks
|
||||
let mut missing: Vec<(String, Vec<String>)> = Vec::new();
|
||||
// Diverged server tips that exist only on the servers
|
||||
let mut backups: Vec<(String, String, Vec<String>)> = Vec::new();
|
||||
|
||||
for url in &urls {
|
||||
let Ok(remote_refs) = repo.ls_remote(url) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for (name, oid) in remote_refs {
|
||||
let Some((_, local_oid)) =
|
||||
local.iter().find(|(local_name, _)| local_name == &name)
|
||||
else {
|
||||
match missing.iter_mut().find(|(known, _)| known == &name) {
|
||||
Some((_, sources)) => sources.push(url.clone()),
|
||||
None => missing.push((name, vec![url.clone()])),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if local_oid == &oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
if repo.merge_base(local_oid, &oid).is_err() {
|
||||
match backups.iter_mut().find(|(known, _, _)| known == &name) {
|
||||
Some((_, _, sources)) => sources.push(url.clone()),
|
||||
None => {
|
||||
backups.push((name.clone(), oid.clone(), vec![url.clone()]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for (name, sources) in &missing {
|
||||
let refspec = format!("+{name}:{name}");
|
||||
if let Err(error) = repo.fetch_refs(sources, &refspec) {
|
||||
log::warn!("failed to fetch {name} from grasp servers: {error}");
|
||||
failures.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (name, oid, sources) in &backups {
|
||||
let refspec = format!("+{name}:refs/nostr/backup/{oid}");
|
||||
if let Err(error) = repo.fetch_refs(sources, &refspec) {
|
||||
log::warn!("failed to back up {name} from grasp servers: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
match failures.is_empty() {
|
||||
true => Ok(missing
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect::<Vec<_>>()),
|
||||
false => bail!(
|
||||
"could not fetch {} from any grasp server",
|
||||
failures.join(", ")
|
||||
),
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
let (mode, policy) = (self.mode, self.policy);
|
||||
*self = Self::from_repo(&snapshot_path)?;
|
||||
self.mode = mode;
|
||||
self.policy = policy;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// State event for these refs; the caller signs it.
|
||||
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
||||
Self::build(repo_id, &self.refs, self.head.as_deref())
|
||||
}
|
||||
|
||||
/// Push the state event and objects to the grasp servers.
|
||||
///
|
||||
/// Calling `on_step` for each step.
|
||||
pub async fn push_to_grasps<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
event: Event,
|
||||
announcement: &Announcement,
|
||||
cx: &mut AsyncApp,
|
||||
on_step: &mut P,
|
||||
) -> Result<PushOutcome, Error>
|
||||
where
|
||||
P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>,
|
||||
{
|
||||
let path = self
|
||||
.path
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("push requires a local repository"))?;
|
||||
|
||||
let owner = announcement
|
||||
.owner
|
||||
.to_bech32()
|
||||
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
||||
|
||||
let mut outcome = PushOutcome::default();
|
||||
let policy = self.policy;
|
||||
let declared: Vec<String> = self
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| git_ref.name.clone())
|
||||
.collect();
|
||||
|
||||
for relay in &announcement.relays {
|
||||
let step = PushStep::StageStateEvent(relay.clone());
|
||||
|
||||
// The relay must accept the state event before the git push is allowed.
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
// Get base URL for the git push
|
||||
let base = grasp_base_url(relay).unwrap_or_default();
|
||||
|
||||
// Ensure the relay is connected before sending the event.
|
||||
client.add_relay(relay).and_connect().await?;
|
||||
|
||||
let staging = client.send_event(&event).to([relay.clone()]).await;
|
||||
// Whether the relay accepted the event
|
||||
let sent_failed = !staging
|
||||
.as_ref()
|
||||
.is_ok_and(|output| output.success.contains_key(relay));
|
||||
|
||||
if sent_failed {
|
||||
let reason = match &staging {
|
||||
Ok(output) => output
|
||||
.failed
|
||||
.get(relay)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "relay did not confirm the event".to_owned()),
|
||||
Err(e) => format!("could not send the state event to {relay}: {e}"),
|
||||
};
|
||||
|
||||
// Run the callback to report the failure
|
||||
on_step(PushStepStatus::Failed(step, reason.clone()), cx)?;
|
||||
|
||||
// Record the failure in the outcome and continue to the next relay
|
||||
outcome.servers.push(GraspServer::failed(relay, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run the callback to report the success
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
|
||||
// Run the git push in the background, reporting progress through the callback
|
||||
let step = PushStep::PushObjects(relay.clone());
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
let pushed = cx.background_spawn({
|
||||
let path = path.to_path_buf();
|
||||
let base = base.clone();
|
||||
let owner = owner.clone();
|
||||
let repo_id = announcement.id.clone();
|
||||
let mode = self.mode;
|
||||
let declared = declared.clone();
|
||||
|
||||
async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
|
||||
match mode {
|
||||
GitPushMode::Main => repo.push_main(&base, &owner, &repo_id),
|
||||
GitPushMode::All => {
|
||||
let mut refspecs = force_refspecs();
|
||||
if policy == GitPushPolicy::ResetRemote {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let server_refs = repo.ls_remote(&url)?;
|
||||
refspecs.extend(deletion_refspecs(&declared, &server_refs));
|
||||
}
|
||||
|
||||
let refspecs = refspecs.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
repo.push_refspecs(&base, &owner, &repo_id, &refspecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match pushed.await {
|
||||
Ok(()) => {
|
||||
// Run the callback to report the success
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
|
||||
outcome.state_event = Some(event.clone());
|
||||
outcome.servers.push(GraspServer::ok(relay.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
// Run the callback to report the failure
|
||||
on_step(PushStepStatus::Failed(step, e.to_string()), cx)?;
|
||||
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay, e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan the state out to the relays once a git server holds the objects.
|
||||
if outcome.state_event.is_some() {
|
||||
let step = PushStep::BroadcastStateEvent;
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
if let Err(e) = client.send_event(&event).broadcast().await {
|
||||
on_step(PushStepStatus::Failed(step, e.to_string()), cx)?;
|
||||
log::warn!("failed to broadcast repository state: {e}");
|
||||
} else {
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Announce the repository and push the state and objects to the grasp servers.
|
||||
///
|
||||
/// Both events are signed by the caller, so a failure at the call site can retract the announcement by id.
|
||||
pub async fn announce_and_push<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
announcement: Event,
|
||||
state_event: Event,
|
||||
cx: &mut AsyncApp,
|
||||
on_step: &mut P,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>,
|
||||
{
|
||||
let step = PushStep::AnnounceRepository;
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
let published = Announcement::from_event(&announcement)
|
||||
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
||||
|
||||
// Ensure the relays are connected before sending the announcement event.
|
||||
for url in &published.relays {
|
||||
client.add_relay(url).and_connect().await.ok();
|
||||
}
|
||||
|
||||
let output = client.send_event(&announcement).broadcast().await?;
|
||||
|
||||
// Handle the case where no relay accepted the announcement event.
|
||||
if output.success.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
// Run the callback to report the failure
|
||||
let step = PushStepStatus::Failed(step, format!("event not accepted: {reasons}"));
|
||||
on_step(step, cx)?;
|
||||
|
||||
bail!("event not accepted: {reasons}");
|
||||
}
|
||||
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
|
||||
let outcome = if self.refs.is_empty() {
|
||||
PushOutcome::default()
|
||||
} else {
|
||||
self.push_to_grasps(client, state_event, &published, cx, on_step)
|
||||
.await?
|
||||
};
|
||||
|
||||
if outcome.accepted() == 0 {
|
||||
bail!(
|
||||
"Repository was announced, but the push to grasp servers failed: {}",
|
||||
outcome.failure_summary()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(announcement)
|
||||
}
|
||||
|
||||
/// Retracts a live announcement; call when `announce_and_push` fails.
|
||||
pub async fn retract_announcement<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
id: EventId,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let builder = EventDeletionRequest::new().id(id);
|
||||
let deletion = builder.finalize_async(signer).await?;
|
||||
let output = client.send_event(&deletion).broadcast().await?;
|
||||
|
||||
// Without relay acceptance the deletion request stays live.
|
||||
if output.success.is_empty() {
|
||||
bail!("deletion event not accepted by any relay");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Refspecs to force push all heads and tags to the grasp servers.
|
||||
fn force_refspecs() -> Vec<String> {
|
||||
vec![
|
||||
"+refs/heads/*:refs/heads/*".to_owned(),
|
||||
"+refs/tags/*:refs/tags/*".to_owned(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Refspecs to delete server-only refs from the grasp servers.
|
||||
fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec<String> {
|
||||
server_refs
|
||||
.iter()
|
||||
.filter(|(name, _)| !declared.contains(name))
|
||||
.map(|(name, _)| format!(":{name}"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::Sender;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use signed_core::{Deletions, Filters, RepoAddr};
|
||||
use signed_git::{Repo, RepoSyncStatus};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::checkouts::CheckoutsStore;
|
||||
use crate::state::SyncState;
|
||||
|
||||
const SYNC_POLL: Duration = Duration::from_secs(180);
|
||||
const MAX_SYNC_CHECKOUTS: usize = 8;
|
||||
|
||||
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);
|
||||
|
||||
impl Global for GlobalSyncStatusStore {}
|
||||
|
||||
/// A checkout compared against the repository state published on Nostr.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutSyncStatus {
|
||||
pub path: PathBuf,
|
||||
pub status: RepoSyncStatus,
|
||||
}
|
||||
|
||||
/// Statuses for these repositories may have changed; empty on signer change.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncEvent {
|
||||
Updated(Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>),
|
||||
}
|
||||
|
||||
/// Probes tracked repositories and broadcasts results. Holds no statuses.
|
||||
pub struct SyncStatusStore {
|
||||
active: RefCell<HashSet<RepoAddr>>,
|
||||
in_flight: bool,
|
||||
pass_pending: bool,
|
||||
poll_pending: bool,
|
||||
sender: Sender<()>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<SyncEvent> for SyncStatusStore {}
|
||||
|
||||
impl SyncStatusStore {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalSyncStatusStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalSyncStatusStore(entity));
|
||||
}
|
||||
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let (sender, receiver) = flume::unbounded::<()>();
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
let backend = Backend::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
// Checkout associations decide which paths get compared.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
this.run_pass(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||
BackendEvent::SignerChanged => this.clear(cx),
|
||||
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx),
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
receiver.recv_async().await?;
|
||||
|
||||
// One pass covers every signal queued since the wake.
|
||||
while receiver.try_recv().is_ok() {}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.run_pass(cx);
|
||||
})?;
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
active: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
in_flight: false,
|
||||
pass_pending: false,
|
||||
poll_pending: false,
|
||||
tasks: vec![task],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a probe pass; folded into any pass already in flight.
|
||||
pub fn probe(&self, addrs: impl IntoIterator<Item = RepoAddr>) {
|
||||
self.active.borrow_mut().extend(addrs);
|
||||
|
||||
if let Err(e) = self.sender.send(()) {
|
||||
log::warn!("failed to queue sync status probe: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the in-flight pass so its completion releases the slot exactly once.
|
||||
fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
let had_addrs = !self.active.borrow().is_empty();
|
||||
self.active.borrow_mut().clear();
|
||||
self.pass_pending = false;
|
||||
|
||||
if had_addrs {
|
||||
cx.emit(SyncEvent::Updated(Vec::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pass(&mut self, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.in_flight {
|
||||
self.pass_pending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
if backend.read(cx).current_user().is_none() {
|
||||
self.clear(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.active.borrow().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.in_flight = true;
|
||||
self.schedule_poll(cx);
|
||||
|
||||
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = {
|
||||
let active = self.active.borrow();
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
active
|
||||
.iter()
|
||||
.map(|addr| {
|
||||
let paths = checkouts.read(cx).associations_of(addr);
|
||||
(addr.clone(), paths)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let db = client.database();
|
||||
let deletion_events = db.query(Filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
let mut updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)> = Vec::new();
|
||||
|
||||
for (addr, paths) in &targets {
|
||||
let events = db.query(addr.state_filter()).await?;
|
||||
let latest = utils::latest(
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|event| !deletions.is_deleted(event)),
|
||||
);
|
||||
|
||||
let Some(event) = latest else {
|
||||
updates.push((addr.clone(), Vec::new()));
|
||||
continue;
|
||||
};
|
||||
|
||||
let remote_refs: Vec<(String, String)> = SyncState::parse(&event)
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||
.collect();
|
||||
|
||||
let mut list = Vec::new();
|
||||
for path in paths.iter().take(MAX_SYNC_CHECKOUTS) {
|
||||
let synced =
|
||||
Repo::try_open(path).and_then(|repo| repo.sync_status(&remote_refs).ok());
|
||||
|
||||
if let Some(status) = synced {
|
||||
list.push(CheckoutSyncStatus {
|
||||
path: path.clone(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updates.push((addr.clone(), list));
|
||||
}
|
||||
|
||||
Ok::<_, Error>(updates)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Reset the slot even on failure, or every later pass folds forever.
|
||||
let updates = match work.await {
|
||||
Ok(updates) => updates,
|
||||
Err(error) => {
|
||||
log::warn!("failed to compute nostr sync statuses: {error}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut again = false;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.in_flight = false;
|
||||
if !updates.is_empty() {
|
||||
cx.emit(SyncEvent::Updated(updates));
|
||||
}
|
||||
again = std::mem::take(&mut this.pass_pending);
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| {
|
||||
this.run_pass(cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn schedule_poll(&mut self, cx: &mut Context<Self>) {
|
||||
if self.poll_pending {
|
||||
return;
|
||||
}
|
||||
self.poll_pending = true;
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(SYNC_POLL).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.poll_pending = false;
|
||||
this.run_pass(cx);
|
||||
})?;
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync status cache bound to one panel or screen.
|
||||
pub struct SyncStatusDirectory {
|
||||
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
|
||||
pending: RefCell<HashSet<RepoAddr>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SyncStatusDirectory {
|
||||
/// Create a directory, following the sync store's events.
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let store = SyncStatusStore::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription =
|
||||
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||
SyncEvent::Updated(updates) => this.apply(updates.clone(), cx),
|
||||
});
|
||||
|
||||
Self {
|
||||
statuses: HashMap::new(),
|
||||
pending: RefCell::new(HashSet::new()),
|
||||
_subscriptions: vec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Queue a probe for addrs this surface renders.
|
||||
pub fn track(&mut self, addrs: impl IntoIterator<Item = RepoAddr>, cx: &mut Context<Self>) {
|
||||
let missing: Vec<RepoAddr> = addrs
|
||||
.into_iter()
|
||||
.filter(|addr| {
|
||||
!self.statuses.contains_key(addr)
|
||||
&& !self.with_pending(|pending| pending.contains(addr))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.with_pending(|pending| pending.extend(missing.iter().cloned()));
|
||||
|
||||
let store = SyncStatusStore::global(cx);
|
||||
store.read(cx).probe(missing);
|
||||
}
|
||||
|
||||
/// Statuses for a repo, or empty; a miss queues a probe.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec<CheckoutSyncStatus> {
|
||||
self.queue(addr, cx);
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Commits available locally but missing on Nostr.
|
||||
pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize {
|
||||
self.queue(addr, cx);
|
||||
|
||||
self.statuses
|
||||
.get(addr)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|checkout| checkout.status.ahead_total)
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn queue(&self, addr: &RepoAddr, cx: &App) {
|
||||
if self.with_pending(|pending| pending.insert(addr.clone())) {
|
||||
let store = SyncStatusStore::global(cx);
|
||||
store.read(cx).probe([addr.clone()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>, cx: &mut Context<Self>) {
|
||||
if updates.is_empty() {
|
||||
let changed = !self.statuses.is_empty();
|
||||
self.statuses.clear();
|
||||
self.with_pending(|pending| pending.clear());
|
||||
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
for (addr, list) in updates {
|
||||
let relevant = self.statuses.contains_key(&addr)
|
||||
|| self.with_pending(|pending| pending.remove(&addr));
|
||||
|
||||
if !relevant {
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.statuses.get(&addr) != Some(&list) {
|
||||
self.statuses.insert(addr, list);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<RepoAddr>) -> R) -> R {
|
||||
let mut pending = self.pending.borrow_mut();
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,110 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
|
||||
use gpui_base::StyledExt;
|
||||
use gpui_component::{ActiveTheme, Colorize};
|
||||
use gpui::{App, Pixels, SharedString, StyleRefinement, Window, div, px};
|
||||
use gpui_base::{AvatarImage, StyledExt};
|
||||
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||
|
||||
/// Number of rows and columns in the pixel grid.
|
||||
const GRID_SIZE: usize = 8;
|
||||
/// Probability that a cell in the left half is filled.
|
||||
/// Empty cells kept between the pattern and the avatar edge.
|
||||
const MARGIN: usize = 1;
|
||||
/// Probability that a cell in the left half of the pattern area is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells.
|
||||
/// A sparse roll still yields a recognizable shape.
|
||||
/// 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.
|
||||
/// The same seed always renders the same avatar.
|
||||
/// An avatar for a user or other entity.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
pub struct Avatar {
|
||||
id: SharedString,
|
||||
name: Option<SharedString>,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Create an avatar seeded from `seed`.
|
||||
/// The seed should be a stable string unique to the entity the avatar represents.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
impl Avatar {
|
||||
/// Create an avatar for `id`, a public key or any other stable string.
|
||||
///
|
||||
/// The id seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new<T>(id: T) -> Self
|
||||
where
|
||||
T: Display,
|
||||
{
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
id: id.to_string().into(),
|
||||
name: None,
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display name of the avatar's subject, if known.
|
||||
pub fn name<T>(mut self, name: T) -> Self
|
||||
where
|
||||
T: Into<Option<SharedString>>,
|
||||
{
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture<T>(mut self, picture: Option<T>) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Avatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Avatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let radius = cx.theme().radius;
|
||||
|
||||
match self.picture {
|
||||
Some(url) => div()
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(radius)
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().secondary)
|
||||
.child(AvatarImage::new(url).size_full().rounded(radius))
|
||||
.refine_style(&self.style)
|
||||
.into_any_element(),
|
||||
None => PixelAvatar {
|
||||
seed: fnv1a(self.id.as_bytes()),
|
||||
side: side_length(self.size),
|
||||
style: self.style,
|
||||
}
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
#[derive(IntoElement)]
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
side: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
@@ -48,16 +117,17 @@ impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let pattern = pattern(self.seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
let hue = self.seed as f32 / u64::MAX as f32;
|
||||
let main = theme.blue.hue(hue);
|
||||
|
||||
let shade = if theme.is_dark() {
|
||||
main.lightness((main.l * 1.6).min(0.95))
|
||||
} else {
|
||||
main.lightness((main.l * 0.45).max(0.18))
|
||||
};
|
||||
|
||||
let mut cells = Vec::new();
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
let value = pattern[row * GRID_SIZE + col];
|
||||
@@ -76,28 +146,42 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(AVATAR_SIZE)
|
||||
.size(self.side)
|
||||
.flex_shrink_0()
|
||||
.rounded(theme.radius)
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
.children(cells)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`.
|
||||
fn side_length(size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => px(16.),
|
||||
Size::Small => px(24.),
|
||||
Size::Medium => px(48.),
|
||||
Size::Large => px(80.),
|
||||
Size::Size(size) => size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the 8×8 cell pattern for `seed`.
|
||||
/// Cells are `0` for empty, `1` for main color and `2` for accent shade.
|
||||
/// The right half mirrors the left half.
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
let mut filled = 0usize;
|
||||
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE / 2 {
|
||||
// Only the inner rows and the inner left half are candidates; mirroring
|
||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||
|
||||
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||
for col in MARGIN..GRID_SIZE / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
@@ -106,18 +190,19 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse rolls can come out nearly empty.
|
||||
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
|
||||
if filled < MIN_FILLED {
|
||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
||||
let start = (rng.next() % half as u64) as usize;
|
||||
for offset in 0..half {
|
||||
let total = art_rows * art_columns;
|
||||
let start = (rng.next() % total as u64) as usize;
|
||||
|
||||
for offset in 0..total {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
let ix = (start + offset) % half;
|
||||
let row = ix / (GRID_SIZE / 2);
|
||||
let col = ix % (GRID_SIZE / 2);
|
||||
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * GRID_SIZE + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
@@ -165,54 +250,3 @@ impl PixelRng {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn count_filled(pattern: &[u8; GRID_SIZE * GRID_SIZE]) -> usize {
|
||||
pattern.iter().filter(|&&cell| cell != 0).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_is_mirror_symmetric() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pattern(seed);
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
assert_eq!(
|
||||
pattern[row * GRID_SIZE + col],
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)],
|
||||
"asymmetric pattern for seed {seed} at ({row}, {col})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_has_minimum_fill() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pattern(seed);
|
||||
assert!(
|
||||
count_filled(&pattern) >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_is_deterministic() {
|
||||
for seed in [0, 1, 42, u64::MAX] {
|
||||
assert_eq!(pattern(seed), pattern(seed));
|
||||
}
|
||||
assert_ne!(pattern(42), pattern(43));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fnv1a_is_stable_and_distinct() {
|
||||
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
|
||||
assert_eq!(fnv1a(b"repo"), fnv1a(b"repo"));
|
||||
assert_ne!(fnv1a(b"repo:a"), fnv1a(b"repo:b"));
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,18 @@ use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// A split dropdown button built on `gpui_base::Popover`.
|
||||
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
||||
/// The action and the caret are caller-supplied elements, so the look stays in the app.
|
||||
/// This component only owns the popover wiring.
|
||||
/// An action element next to a caret that opens a [`PopupMenu`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
caret: Option<CaretBuilder>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
||||
|
||||
impl DropdownButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
@@ -32,7 +28,6 @@ impl DropdownButton {
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
caret: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
@@ -54,14 +49,6 @@ impl DropdownButton {
|
||||
self.menu = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
|
||||
/// Which corner of the caret the menu anchors to.
|
||||
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
|
||||
#[allow(dead_code)] // API knob, current call sites use the default anchor.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for DropdownButton {
|
||||
@@ -91,28 +78,24 @@ impl RenderOnce for DropdownButton {
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
let caret = self.caret.unwrap_or_else(|| {
|
||||
let id = popover_id.clone();
|
||||
Box::new(move |is_open, _, cx| {
|
||||
let caret = default_caret(id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
let caret_id = popover_id.clone();
|
||||
this.child(
|
||||
Popover::new(popover_id)
|
||||
.anchor(anchor)
|
||||
// The menu dismisses itself on outside click or Escape.
|
||||
// The subscription below closes the popover along with it.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.trigger_with(move |is_open, _, cx| {
|
||||
let caret = default_caret(caret_id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
@@ -162,24 +145,3 @@ fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::div;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dropdown_button_builder_state() {
|
||||
let button = DropdownButton::new("issues")
|
||||
.action(div())
|
||||
.anchor(Anchor::BottomLeft)
|
||||
.dropdown_menu(|menu, _, _| menu);
|
||||
|
||||
assert!(button.action.is_some());
|
||||
// The caret is `None` until render, which falls back to the default.
|
||||
assert!(button.caret.is_none());
|
||||
assert!(button.menu.is_some());
|
||||
assert_eq!(button.anchor, Anchor::BottomLeft);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
mod avatar;
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
mod placeholder;
|
||||
mod ref_selector;
|
||||
mod segment_button;
|
||||
mod setting;
|
||||
mod status_badge;
|
||||
mod title_bar;
|
||||
mod tree_row;
|
||||
mod user_avatar;
|
||||
|
||||
pub mod copy_row;
|
||||
pub mod util;
|
||||
|
||||
pub use avatar::Avatar;
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
pub use ref_selector::ref_selector_trigger;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
pub use setting::{SelectOption, setting_block, setting_row};
|
||||
pub use status_badge::status_badge;
|
||||
pub use title_bar::title_bar_drag_handlers;
|
||||
pub use tree_row::tree_row;
|
||||
pub use user_avatar::UserAvatar;
|
||||
pub use util::middle_truncate;
|
||||
|
||||
@@ -35,7 +35,6 @@ impl NavItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// A trailing element rendered at the right edge of the row
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
|
||||
/// The kind icon, the selection or placeholder, and the caret. `Combobox`
|
||||
/// replaces its default trigger entirely, the only way to show an icon inside it.
|
||||
pub fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -4,8 +4,6 @@ use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// A small count badge shown after a label.
|
||||
/// Used on segmented filter buttons, like `All 12`, and on tabs.
|
||||
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CountBadge {
|
||||
count: usize,
|
||||
|
||||
@@ -4,8 +4,6 @@ use gpui_component::searchable_list::SearchableListItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// A dropdown option with a display label and a stored value.
|
||||
/// The trigger and menu render the `label`.
|
||||
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
|
||||
#[derive(Clone)]
|
||||
pub struct SelectOption {
|
||||
value: SharedString,
|
||||
@@ -21,12 +19,10 @@ impl SelectOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value of this option.
|
||||
pub fn value(&self) -> &SharedString {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// The display label of this option.
|
||||
pub fn label(&self) -> &SharedString {
|
||||
&self.label
|
||||
}
|
||||
@@ -82,7 +78,6 @@ pub fn setting_row(
|
||||
}
|
||||
|
||||
/// A full-width settings block with title and subtitle in one header.
|
||||
/// `gap_3` separates the header from the control below.
|
||||
pub fn setting_block(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
|
||||
@@ -5,8 +5,7 @@ use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// The status badge shown next to an issue or pull request.
|
||||
/// It has an icon and a colored square, with a tooltip describing the status.
|
||||
/// Status badge shown next to an issue or pull request.
|
||||
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
|
||||
@@ -9,11 +9,6 @@ struct WindowDragState {
|
||||
}
|
||||
|
||||
/// Make an element behave like a window title bar.
|
||||
/// Dragging it moves the window.
|
||||
/// Double-clicking zooms the window.
|
||||
/// On macOS it runs the platform's default title-bar double-click action.
|
||||
/// Only the bar's non-interactive areas should get this.
|
||||
/// Tabs are draggable to reorder panels and must not move the window.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
|
||||
@@ -4,9 +4,6 @@ use gpui_component::list::ListItem;
|
||||
use gpui_component::tree::TreeEntry;
|
||||
use gpui_component::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// One row of a file tree, an icon and a name indented by depth.
|
||||
/// Clicking a file runs `on_click`.
|
||||
/// Folders expand and collapse via the tree itself.
|
||||
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
@@ -35,7 +32,6 @@ where
|
||||
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
// Folders expand/collapse via the tree itself.
|
||||
if is_folder {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||
|
||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
||||
/// It shows the user's picture or falls back to name initials.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UserAvatar {
|
||||
name: SharedString,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl UserAvatar {
|
||||
/// Create an avatar for `name`.
|
||||
/// The name seeds the initials fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for UserAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for UserAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for UserAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
Avatar::new()
|
||||
.name(self.name)
|
||||
.when_some(self.picture, |this, url| this.src(url))
|
||||
.rounded(cx.theme().radius)
|
||||
.refine_style(&self.style)
|
||||
.with_size(self.size)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/// `[head chars]...[tail chars]` middle truncation.
|
||||
///
|
||||
/// Values too short for the ellipsis to save space are left alone.
|
||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||
let len = value.chars().count();
|
||||
if len <= head + tail + 3 {
|
||||
return value.to_string();
|
||||
}
|
||||
let head: String = value.chars().take(head).collect();
|
||||
let tail: String = value.chars().skip(len - tail).collect();
|
||||
format!("{head}...{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn middle_truncates_long_values_only() {
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
|
||||
10,
|
||||
10,
|
||||
),
|
||||
"a008def157...ad57a3564d"
|
||||
);
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
|
||||
10,
|
||||
10
|
||||
),
|
||||
"30617:a008...3564d:ngit"
|
||||
);
|
||||
// Too short to save space with the ellipsis, left alone.
|
||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
mod pubkey;
|
||||
mod repo_url;
|
||||
mod text;
|
||||
mod time;
|
||||
|
||||
pub use pubkey::shorten_pubkey;
|
||||
pub use time::{relative_time, relative_time_secs};
|
||||
pub use repo_url::same_repo_url;
|
||||
pub use text::{flatten_whitespace, middle_truncate};
|
||||
pub use time::{latest, relative_time, relative_time_secs, sort_newest_first};
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||
let npub = public_key.to_bech32().unwrap();
|
||||
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
|
||||
use crate::text::middle_truncate;
|
||||
|
||||
const HEAD_CHARS: usize = 9;
|
||||
const TAIL_CHARS: usize = 4;
|
||||
|
||||
pub fn shorten_pubkey(public_key: PublicKey) -> String {
|
||||
let encoded = public_key
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| public_key.to_hex());
|
||||
|
||||
middle_truncate(&encoded, HEAD_CHARS, TAIL_CHARS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Compare git URLs ignoring scheme, trailing `.git` and path slashes,
|
||||
/// so a grasp announce URL matches its https origin.
|
||||
pub fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
match (url_identity(a), url_identity(b)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
let mut path = parsed.path().trim_matches('/').to_owned();
|
||||
if let Some(stripped) = path.strip_suffix(".git") {
|
||||
path = stripped.to_owned();
|
||||
}
|
||||
Some((host, parsed.port(), path))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||
let len = value.chars().count();
|
||||
if len <= head + tail + 3 {
|
||||
return value.to_string();
|
||||
}
|
||||
let head: String = value.chars().take(head).collect();
|
||||
let tail: String = value.chars().skip(len - tail).collect();
|
||||
format!("{head}...{tail}")
|
||||
}
|
||||
|
||||
pub fn flatten_whitespace(text: &str) -> String {
|
||||
const MAX_CHARS: usize = 200;
|
||||
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if flat.chars().count() <= MAX_CHARS {
|
||||
flat
|
||||
} else {
|
||||
let mut clipped: String = flat.chars().take(MAX_CHARS).collect();
|
||||
clipped.push('…');
|
||||
clipped
|
||||
}
|
||||
}
|
||||
+15
-24
@@ -1,6 +1,20 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Format a timestamp as a short relative time, e.g. `3h ago`.
|
||||
pub fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn latest<I>(events: I) -> Option<Event>
|
||||
where
|
||||
I: IntoIterator<Item = Event>,
|
||||
{
|
||||
events.into_iter().max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
pub fn relative_time(timestamp: Timestamp) -> String {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let secs = now.saturating_sub(timestamp.as_secs());
|
||||
@@ -20,29 +34,6 @@ pub fn relative_time(timestamp: Timestamp) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
|
||||
pub fn relative_time_secs(secs: i64) -> String {
|
||||
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_relative_time() {
|
||||
let now = Timestamp::now();
|
||||
|
||||
assert_eq!(relative_time(now), "just now");
|
||||
assert_eq!(relative_time(now - 300), "5m ago");
|
||||
assert_eq!(relative_time(now - 7_200), "2h ago");
|
||||
assert_eq!(relative_time(now - 3 * 86_400), "3d ago");
|
||||
assert_eq!(relative_time(now - 60 * 86_400), "2mo ago");
|
||||
assert_eq!(relative_time(now - 800 * 86_400), "2y ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_future_timestamps() {
|
||||
assert_eq!(relative_time(Timestamp::now() + 600), "just now");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
dock = { workspace = true }
|
||||
metadata = { path = "../metadata" }
|
||||
paths = { path = "../paths" }
|
||||
settings = { path = "../settings" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
@@ -18,7 +19,6 @@ utils = { path = "../utils" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-fps.workspace = true
|
||||
gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
|
||||
+208
-38
@@ -13,34 +13,30 @@ use gpui_component::resizable::{resizable_panel, v_resizable};
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_git::{
|
||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||
};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
use crate::views::tree::{build_tree_items, tree_items};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
/// Tree and per-file diff body, shared by the commit diff and compare views.
|
||||
pub struct DiffPane {
|
||||
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
||||
diff: Option<CommitDiff>,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
rows: Vec<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the diff rows.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
}
|
||||
|
||||
@@ -56,12 +52,10 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// The loaded diff, for stats and badges in the host's header.
|
||||
pub fn diff(&self) -> Option<&CommitDiff> {
|
||||
self.diff.as_ref()
|
||||
}
|
||||
|
||||
/// Replace the diff and rebuild the tree and the selected file's rows.
|
||||
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
@@ -86,9 +80,6 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the diff, e.g. when the compared branches changed.
|
||||
///
|
||||
/// Clears the tree, the selection and the diff rows.
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
self.diff = None;
|
||||
self.selected_file = None;
|
||||
@@ -99,14 +90,12 @@ impl DiffPane {
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the diff of the file at `path`, selected in the tree.
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Rebuild the virtual list state for `path` and scroll back to the top.
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
@@ -119,7 +108,6 @@ impl DiffPane {
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
/// One row of the changed-files tree, icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
@@ -136,7 +124,6 @@ impl DiffPane {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the changed-files tree.
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
@@ -166,7 +153,6 @@ impl DiffPane {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right column, header of the selected file plus its diff.
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("No changes", cx);
|
||||
@@ -184,7 +170,6 @@ impl DiffPane {
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
/// The diff of one file, with a header showing status and stats.
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
DiffStatus::Added => "A",
|
||||
@@ -311,24 +296,24 @@ impl Render for DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail panel showing the diff of one commit.
|
||||
pub struct CommitDiffView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Local clone the commit lives in.
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
/// Display name of the repository the commit belongs to.
|
||||
repo_name: SharedString,
|
||||
/// The commit being shown in the header and tab title.
|
||||
commit: FileCommit,
|
||||
/// The diff is being computed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||
pane: Entity<DiffPane>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
impl CommitDiffView {
|
||||
pub fn new(
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
repo_name: SharedString,
|
||||
commit_id: String,
|
||||
@@ -336,6 +321,13 @@ impl CommitDiffView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let pane = cx.new(DiffPane::new);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
// Defer until the window is ready, like the repository detail view.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
@@ -344,6 +336,7 @@ impl CommitDiffView {
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
worktree,
|
||||
repo_name,
|
||||
commit: FileCommit {
|
||||
@@ -356,10 +349,11 @@ impl CommitDiffView {
|
||||
loading: true,
|
||||
error: None,
|
||||
pane,
|
||||
directory,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the commit diff and the full commit metadata.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -374,14 +368,14 @@ impl CommitDiffView {
|
||||
.background_spawn({
|
||||
let worktree = worktree.clone();
|
||||
let id = id.clone();
|
||||
async move { signed_git::worktree_commit(&worktree, &id) }
|
||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
|
||||
})
|
||||
.await;
|
||||
let diff = cx
|
||||
.background_spawn({
|
||||
let worktree = worktree.clone();
|
||||
let id = id.clone();
|
||||
async move { signed_git::worktree_commit_diff(&worktree, &id) }
|
||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -404,10 +398,9 @@ impl CommitDiffView {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Header with the commit id, summary, author/time and overall change stats.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
@@ -492,11 +485,11 @@ impl BasePanel for CommitDiffView {
|
||||
}
|
||||
|
||||
impl Panel for CommitDiffView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().text_sm().child(SharedString::from(format!(
|
||||
"{}/{}",
|
||||
self.repo_name, self.commit.id
|
||||
)))
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,3 +528,180 @@ impl Render for CommitDiffView {
|
||||
.child(resizable_panel().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
const GUTTER_WIDTH: f32 = 44.;
|
||||
const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
Line {
|
||||
hunk: usize,
|
||||
line: usize,
|
||||
},
|
||||
}
|
||||
|
||||
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(crate) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,23 +1,60 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::ActiveTheme;
|
||||
use gpui::{AnyElement, App, Context, SharedString, Subscription, div};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use signed_core::RepoAddr;
|
||||
use signed_state::{Backend, BackendEvent, PushStepStatus};
|
||||
|
||||
/// Progress of an async dialog action: a busy flag disabling the form,
|
||||
/// and an error line shown under it.
|
||||
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
// The repository whose push checklist is shown; `None` outside a publish.
|
||||
push_addr: Option<RepoAddr>,
|
||||
pub push_steps: Vec<PushStepStatus>,
|
||||
_push_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
/// An action started, disable the form and clear the previous error.
|
||||
/// Marks an action as started, disabling the form and clearing the previous error.
|
||||
pub fn begin(&mut self) {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// An action failed, re-enable the form and surface `message`.
|
||||
/// Starts a publish for the repository at `addr` and listens for its push steps.
|
||||
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
self.begin();
|
||||
self.push_addr = Some(addr);
|
||||
self.push_steps.clear();
|
||||
|
||||
if self._push_subscription.is_none() {
|
||||
let backend = Backend::global(cx);
|
||||
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let BackendEvent::PushProgress { addr, status } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if this.push_addr.as_ref() != Some(addr) {
|
||||
return;
|
||||
}
|
||||
|
||||
match this
|
||||
.push_steps
|
||||
.iter_mut()
|
||||
.find(|step| step.step() == status.step())
|
||||
{
|
||||
Some(step) => *step = status.clone(),
|
||||
None => this.push_steps.push(status.clone()),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks an action as failed, enabling the form and showing `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
self.error = Some(message.into());
|
||||
@@ -34,3 +71,46 @@ pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
/// The push checklist of a running publish, `None` before any step is reported.
|
||||
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
|
||||
if steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rows = steps
|
||||
.iter()
|
||||
.map(|status| {
|
||||
let label = SharedString::from(status.step().label());
|
||||
|
||||
match status {
|
||||
PushStepStatus::Running(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Done(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(Icon::new(IconName::CircleCheck).small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Failed(_, reason) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(Icon::new(IconName::Close).small())
|
||||
.child(SharedString::from(format!(
|
||||
"{}: {reason}",
|
||||
status.step().label()
|
||||
)))
|
||||
.into_any_element(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(v_flex().gap_1().children(rows).into_any_element())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user