Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a87030e929 | ||
|
|
272ddf2674 | ||
|
|
fa50e9f153 | ||
|
|
da22d26019 | ||
|
|
0ff6740a9e | ||
|
|
e2bce4e074 | ||
|
|
a85b5ee87b | ||
|
|
e61caf8482 | ||
|
|
a02e44c0f8 | ||
|
|
b526e13f45 | ||
|
|
5e6156066a | ||
|
|
a74c166391 |
@@ -159,7 +159,7 @@ jobs:
|
|||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
server_url: "https://git.reya.su/"
|
server_url: "https://git.reya.info/"
|
||||||
repository: "reya/signed"
|
repository: "reya/signed"
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ dist/
|
|||||||
|
|
||||||
# Snap and Flatpak local build output
|
# Snap and Flatpak local build output
|
||||||
/snap
|
/snap
|
||||||
/su.reya.signed.json
|
/info.reya.signed.json
|
||||||
/linux-artifacts
|
/linux-artifacts
|
||||||
|
|
||||||
# Vendored dependencies + cargo config generated by script/prepare-flathub
|
# 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,152 @@
|
|||||||
.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.
|
||||||
|
* 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)`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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,50 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
## 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
+632
-572
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -4,20 +4,18 @@ members = ["crates/*", "desktop"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0-alpha"
|
version = "0.2.0-alpha"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# GPUI
|
gpui = { package = "gpui-pre", version = "0.3.6" }
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" }
|
||||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|
||||||
|
|
||||||
# GPUI Kit
|
# GPUI Kit
|
||||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] }
|
||||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
gpui-base = "0.6.6"
|
||||||
|
|
||||||
dock = { path = "crates/dock" }
|
dock = { path = "crates/dock" }
|
||||||
settings = { path = "crates/settings" }
|
settings = { path = "crates/settings" }
|
||||||
|
|||||||
@@ -77,9 +77,9 @@
|
|||||||
"success.foreground": "#052E16",
|
"success.foreground": "#052E16",
|
||||||
"switch.background": "#CECECE",
|
"switch.background": "#CECECE",
|
||||||
"switch.thumb.background": "#FFFFFF",
|
"switch.thumb.background": "#FFFFFF",
|
||||||
"tab.background": "#00000000",
|
"tab.background": "#E8E8E8",
|
||||||
"tab.active.background": "#EBFFC1",
|
"tab.active.background": "#F5F5F5",
|
||||||
"tab.active.foreground": "#3F6212",
|
"tab.active.foreground": "#18181B",
|
||||||
"tab.foreground": "#646464",
|
"tab.foreground": "#646464",
|
||||||
"tab_bar.background": "#F0F0F0",
|
"tab_bar.background": "#F0F0F0",
|
||||||
"tab_bar.segmented.background": "#F0F0F0",
|
"tab_bar.segmented.background": "#F0F0F0",
|
||||||
@@ -286,9 +286,9 @@
|
|||||||
"success.foreground": "#052E16",
|
"success.foreground": "#052E16",
|
||||||
"switch.background": "#484848",
|
"switch.background": "#484848",
|
||||||
"switch.thumb.background": "#0A0A0A",
|
"switch.thumb.background": "#0A0A0A",
|
||||||
"tab.background": "#00000000",
|
"tab.background": "#2E2E2E",
|
||||||
"tab.active.background": "#19200A",
|
"tab.active.background": "#2A2A2A",
|
||||||
"tab.active.foreground": "#C6FF4D",
|
"tab.active.foreground": "#FAFAFA",
|
||||||
"tab.foreground": "#B4B4B4",
|
"tab.foreground": "#B4B4B4",
|
||||||
"tab_bar.background": "#191919",
|
"tab_bar.background": "#191919",
|
||||||
"tab_bar.segmented.background": "#191919",
|
"tab_bar.segmented.background": "#191919",
|
||||||
|
|||||||
@@ -39,12 +39,7 @@ impl Assets {
|
|||||||
.filter_map(|path| {
|
.filter_map(|path| {
|
||||||
let data = Self::get(path.as_ref())?;
|
let data = Self::get(path.as_ref())?;
|
||||||
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
||||||
let content = match data.data {
|
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
|
||||||
std::borrow::Cow::Borrowed(bytes) => {
|
|
||||||
std::str::from_utf8(bytes).ok()?.to_owned()
|
|
||||||
}
|
|
||||||
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
|
|
||||||
};
|
|
||||||
Some((name.to_owned(), content))
|
Some((name.to_owned(), content))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
gpui-base.workspace = true
|
gpui-base.workspace = true
|
||||||
|
settings = { path = "../settings" }
|
||||||
signed_ui = { path = "../signed_ui" }
|
signed_ui = { path = "../signed_ui" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -11,22 +11,19 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||||
PanelView, TabGroupRenderer, TilesRenderer,
|
PanelView, TabGroupRenderer,
|
||||||
};
|
};
|
||||||
use gpui_base::resize_handle;
|
use gpui_base::resize_handle;
|
||||||
use gpui_component::scroll::ScrollbarMode;
|
use gpui_component::{ActiveTheme as _, Side};
|
||||||
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
|
|
||||||
|
|
||||||
use crate::invalid_panel::InvalidPanel;
|
use crate::invalid_panel::InvalidPanel;
|
||||||
|
use crate::panel_handle;
|
||||||
use crate::tab_panel::SignedTabGroupSkin;
|
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.
|
/// State the skin shares with its per-container renderers.
|
||||||
pub(crate) struct SkinShared {
|
pub(crate) struct SkinShared {
|
||||||
area: WeakEntity<DockArea>,
|
area: WeakEntity<DockArea>,
|
||||||
toggle_button_visible: Cell<bool>,
|
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.
|
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||||
resizing_dock: Cell<Option<DockPlacement>>,
|
resizing_dock: Cell<Option<DockPlacement>>,
|
||||||
}
|
}
|
||||||
@@ -40,10 +37,6 @@ impl SkinShared {
|
|||||||
self.toggle_button_visible.get()
|
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>> {
|
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||||
&self.resizing_dock
|
&self.resizing_dock
|
||||||
}
|
}
|
||||||
@@ -73,7 +66,6 @@ impl SignedDockSkin {
|
|||||||
shared: Rc::new(SkinShared {
|
shared: Rc::new(SkinShared {
|
||||||
area: cx.weak_entity(),
|
area: cx.weak_entity(),
|
||||||
toggle_button_visible: Cell::new(true),
|
toggle_button_visible: Cell::new(true),
|
||||||
tiles_scrollbar_mode: Cell::new(None),
|
|
||||||
resizing_dock: Cell::new(None),
|
resizing_dock: Cell::new(None),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -92,16 +84,6 @@ impl SignedDockSkin {
|
|||||||
self.shared.toggle_button_visible.set(visible);
|
self.shared.toggle_button_visible.set(visible);
|
||||||
self.shared.notify(cx);
|
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.
|
/// Payload a dock's resize handle drags.
|
||||||
@@ -154,30 +136,10 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> 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()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_none()
|
.size_full()
|
||||||
.relative()
|
.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(content)
|
||||||
.child(self.render_resize_handle(dock, window, cx))
|
.child(self.render_resize_handle(dock, window, cx))
|
||||||
.child(DockResizeTracker {
|
.child(DockResizeTracker {
|
||||||
@@ -204,10 +166,6 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
|
||||||
Rc::new(SignedTilesSkin::new(self.shared().clone()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignedDockSkin {
|
impl SignedDockSkin {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use gpui_base::dock::PanelView;
|
|||||||
mod dock_area;
|
mod dock_area;
|
||||||
mod invalid_panel;
|
mod invalid_panel;
|
||||||
mod tab_panel;
|
mod tab_panel;
|
||||||
mod tiles;
|
|
||||||
mod window_controls;
|
mod window_controls;
|
||||||
|
|
||||||
pub use dock_area::SignedDockSkin;
|
pub use dock_area::SignedDockSkin;
|
||||||
|
|||||||
+306
-175
@@ -5,9 +5,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
|
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
||||||
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
|
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
||||||
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
Styled as _, Window, div, px, size,
|
Styled as _, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_base::dock::{
|
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::animation::{Lerp as _, ease_out_cubic};
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
use gpui_component::button::{Button, ButtonVariants as _};
|
||||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||||
use gpui_component::menu::DropdownMenu as _;
|
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||||
use gpui_component::{
|
use settings::{SettingsStore, TabBarSettings};
|
||||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
|
||||||
};
|
|
||||||
use signed_ui::title_bar_drag_handlers;
|
use signed_ui::title_bar_drag_handlers;
|
||||||
|
|
||||||
use crate::dock_area::SkinShared;
|
use crate::dock_area::SkinShared;
|
||||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||||
|
|
||||||
/// The drag preview's size, reported to base for the drop placeholder.
|
|
||||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
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.
|
/// A panel's title, or its registered name when the panel has no handle.
|
||||||
pub(crate) fn panel_title(
|
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.
|
/// 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 {
|
struct DragPanelPreview {
|
||||||
panel: Arc<dyn BasePanelView>,
|
panel: Arc<dyn BasePanelView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for DragPanelPreview {
|
impl Render for DragPanelPreview {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div()
|
h_flex()
|
||||||
.id("drag-panel")
|
.id("drag-panel")
|
||||||
.cursor_grab()
|
.cursor_grab()
|
||||||
.py_1()
|
.p_1()
|
||||||
.px_3()
|
|
||||||
.w_24()
|
|
||||||
.overflow_hidden()
|
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius_lg)
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
.bg(cx.theme().tokens.tab_active)
|
.bg(cx.theme().tokens.tab_active)
|
||||||
.opacity(0.75)
|
.opacity(0.75)
|
||||||
@@ -69,9 +72,14 @@ impl Render for DragPanelPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
|
||||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||||
let panel = group.active_panel()?;
|
panel_zoom_control(group.active_panel()?, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom affordance `panel` offers, if any.
|
||||||
|
///
|
||||||
|
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||||
|
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||||
panel
|
panel
|
||||||
.zoomable(cx)
|
.zoomable(cx)
|
||||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||||
@@ -79,16 +87,17 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The left-most, top-most tab group in a container.
|
/// The left-most, top-most tab group in a container.
|
||||||
|
///
|
||||||
/// A left dock's collapse button lives in this group.
|
/// A left dock's collapse button lives in this group.
|
||||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
PaneRef::Tabs { .. } => Some(node.id()),
|
PaneRef::Tabs { .. } => Some(node.id()),
|
||||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||||
PaneRef::Tiles { .. } => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The right-most, top-most tab group.
|
/// The right-most, top-most tab group.
|
||||||
|
///
|
||||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
@@ -98,7 +107,53 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
gpui::Axis::Horizontal => children.last(),
|
gpui::Axis::Horizontal => children.last(),
|
||||||
}
|
}
|
||||||
.and_then(right_top_group),
|
.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 +199,14 @@ impl SignedTabGroupSkin {
|
|||||||
left == group.node() && group.panels().len() == 1
|
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.
|
/// 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.
|
/// 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> {
|
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||||
if group.is_locked() {
|
if group.is_locked() {
|
||||||
return None;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
group.drag_panel(ix, cx)
|
group.drag_panel(ix, cx)
|
||||||
@@ -198,7 +241,7 @@ impl SignedTabGroupSkin {
|
|||||||
DockPlacement::Bottom => area
|
DockPlacement::Bottom => area
|
||||||
.layout(DockPlacement::Bottom)
|
.layout(DockPlacement::Bottom)
|
||||||
.and_then(|tree| left_top_group(tree.root())),
|
.and_then(|tree| left_top_group(tree.root())),
|
||||||
DockPlacement::Center => None,
|
DockPlacement::Center => return None,
|
||||||
};
|
};
|
||||||
if designated != Some(group.node()) {
|
if designated != Some(group.node()) {
|
||||||
return None;
|
return None;
|
||||||
@@ -233,15 +276,22 @@ impl SignedTabGroupSkin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
/// 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(
|
/// Hidden by default, and only offered for crowded strips where the active
|
||||||
&self,
|
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
||||||
group: &TabGroupContext,
|
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
||||||
_cx: &mut App,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let collapsed = group.is_collapsed();
|
let collapsed = group.is_collapsed();
|
||||||
let active_ix = group.active_ix();
|
let active_ix = group.active_ix();
|
||||||
let panels_len = group.panels().len();
|
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 prev_enabled = !collapsed && active_ix > 0;
|
||||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||||
|
|
||||||
@@ -273,100 +323,10 @@ impl SignedTabGroupSkin {
|
|||||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
.into_any_element()
|
||||||
|
|
||||||
/// 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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tab of the pill strip.
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_tab(
|
fn render_tab(
|
||||||
&self,
|
&self,
|
||||||
@@ -382,25 +342,73 @@ impl SignedTabGroupSkin {
|
|||||||
let droppable = group.is_droppable();
|
let droppable = group.is_droppable();
|
||||||
let drag = self.tab_drag(group, ix, cx);
|
let drag = self.tab_drag(group, ix, cx);
|
||||||
let handle = PanelHandle::of(&panel);
|
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)
|
Tab::new(ix)
|
||||||
.h_6()
|
.group(TAB_HOVER_GROUP)
|
||||||
.px_3()
|
.when(active, |this| {
|
||||||
.text_sm()
|
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||||
.whitespace_nowrap()
|
})
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
|
||||||
.gap_1()
|
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.h_7()
|
||||||
|
.min_w_40()
|
||||||
|
.gap_1()
|
||||||
|
.px_1p5()
|
||||||
|
.text_sm()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_color(cx.theme().foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
.child(
|
||||||
Some(tab_name) => this.child(tab_name),
|
h_flex()
|
||||||
None => this.child(panel_title(&panel, window, cx)),
|
.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(|styles| {
|
||||||
styles.selected(|style| {
|
styles.selected(|style| {
|
||||||
style
|
style
|
||||||
@@ -412,15 +420,15 @@ impl SignedTabGroupSkin {
|
|||||||
if active {
|
if active {
|
||||||
this
|
this
|
||||||
} else {
|
} else {
|
||||||
this.text_color(cx.theme().secondary_foreground)
|
this.text_color(cx.theme().tab_foreground)
|
||||||
.bg(cx.theme().secondary_hover)
|
.bg(cx.theme().tab)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.selected(active)
|
.selected(active)
|
||||||
.on_click({
|
.on_click({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let area = self.shared.area().clone();
|
let area = self.shared.area().clone();
|
||||||
move |_, window, cx| {
|
move |event, window, cx| {
|
||||||
group.select_tab(ix, window, cx);
|
group.select_tab(ix, window, cx);
|
||||||
|
|
||||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||||
@@ -428,7 +436,15 @@ impl SignedTabGroupSkin {
|
|||||||
_ = area.update(cx, |area, cx| {
|
_ = area.update(cx, |area, cx| {
|
||||||
area.toggle_dock(DockPlacement::Bottom, window, 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| {
|
.when(!collapsed, |this| {
|
||||||
@@ -531,10 +547,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
if group.panels().is_empty() {
|
if group.panels().is_empty() {
|
||||||
return div().id("tab-panel");
|
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();
|
let shared = self.shared.clone();
|
||||||
|
|
||||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||||
@@ -543,14 +555,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(cx.theme().tokens.background)
|
.bg(cx.theme().tokens.background)
|
||||||
// A collapsed group has no content, so these actions are not registered.
|
|
||||||
.when(!group.is_collapsed(), |this| {
|
.when(!group.is_collapsed(), |this| {
|
||||||
this.on_action({
|
this.on_action({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
move |_: &ToggleZoom, window, cx| {
|
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() {
|
if !group.is_zoomed() && control.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -564,18 +572,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
let Some(panel) = group.active_panel() else {
|
let Some(panel) = group.active_panel() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !panel.closable(cx) {
|
close_panel(&shared, &group, panel, window, 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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -613,17 +610,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||||
let is_bottom_dock = bottom_dock_button.is_some();
|
let is_bottom_dock = bottom_dock_button.is_some();
|
||||||
|
|
||||||
// On macOS the traffic lights overlay the window's top-left corner.
|
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||||
// Only the tab bar that sits under them reserves the space.
|
// tab bar under them reserves the space. That is the center's top-left group
|
||||||
// That is the center's top-left group when the left dock is closed or absent.
|
// 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")
|
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||||
let area = area.read(cx);
|
let area = area.read(cx);
|
||||||
!area.is_dock_open(DockPlacement::Left)
|
match area.zoomed_group() {
|
||||||
&& area
|
Some(zoomed) => zoomed == group.node(),
|
||||||
.layout(DockPlacement::Center)
|
None => {
|
||||||
.and_then(|tree| left_top_group(tree.root()))
|
!area.is_dock_open(DockPlacement::Left)
|
||||||
== Some(group.node())
|
&& area
|
||||||
|
.layout(DockPlacement::Center)
|
||||||
|
.and_then(|tree| left_top_group(tree.root()))
|
||||||
|
== Some(group.node())
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bring a newly displayed tab into view.
|
// Bring a newly displayed tab into view.
|
||||||
@@ -709,7 +712,16 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.child(
|
.child(
|
||||||
Tabs::new("tab-bar")
|
Tabs::new("tab-bar")
|
||||||
.px(px(-1.))
|
.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()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
@@ -763,7 +775,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.and_then(PanelHandle::of)
|
.and_then(PanelHandle::of)
|
||||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||||
)
|
)
|
||||||
.child(self.render_toolbar(group, window, cx))
|
|
||||||
.children(right_dock_button),
|
.children(right_dock_button),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -833,3 +844,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,388 +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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,9 +90,13 @@ pub fn nostr_dir() -> &'static PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the path to the local git clone cache, the grasp mirrors.
|
/// 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 {
|
pub fn repos_dir() -> &'static PathBuf {
|
||||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn settings_file() -> &'static PathBuf {
|
pub fn settings_file() -> &'static PathBuf {
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ pub enum AppearanceMode {
|
|||||||
Dark,
|
Dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -54,6 +64,22 @@ impl Default for ThemeSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct GraspServersSettings {
|
pub struct GraspServersSettings {
|
||||||
@@ -125,7 +151,9 @@ pub struct CreateRepositorySettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub appearance: AppearanceMode,
|
pub appearance: AppearanceMode,
|
||||||
|
pub event_fetching: EventFetchingStrategy,
|
||||||
pub theme: ThemeSettings,
|
pub theme: ThemeSettings,
|
||||||
|
pub tab_bar: TabBarSettings,
|
||||||
pub grasp_servers: GraspServersSettings,
|
pub grasp_servers: GraspServersSettings,
|
||||||
pub local_repos: LocalReposSettings,
|
pub local_repos: LocalReposSettings,
|
||||||
pub checkouts: CheckoutsSettings,
|
pub checkouts: CheckoutsSettings,
|
||||||
@@ -140,6 +168,7 @@ mod tests {
|
|||||||
fn json_roundtrip_preserves_everything() {
|
fn json_roundtrip_preserves_everything() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
appearance: AppearanceMode::Dark,
|
appearance: AppearanceMode::Dark,
|
||||||
|
event_fetching: EventFetchingStrategy::Curated,
|
||||||
theme: ThemeSettings {
|
theme: ThemeSettings {
|
||||||
radius: 8.0,
|
radius: 8.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -161,9 +190,19 @@ mod tests {
|
|||||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||||
assert_eq!(settings.theme.radius, 4.0);
|
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.
|
// The rest of the theme and the other groups keep their defaults.
|
||||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||||
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||||
assert_eq!(settings.create_repository.default_folder, None);
|
assert_eq!(settings.create_repository.default_folder, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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,6 +24,12 @@ impl SettingsStore {
|
|||||||
cx.global::<GlobalSettingsStore>().0.clone()
|
cx.global::<GlobalSettingsStore>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalSettingsStore(entity));
|
cx.set_global(GlobalSettingsStore(entity));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,248 +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 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +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 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,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
@@ -18,19 +18,18 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPullRequest,
|
Kind::GitPullRequest,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
Kind::GitPullRequestUpdate,
|
Kind::GitPullRequestUpdate,
|
||||||
COVER_NOTE_KIND,
|
|
||||||
Kind::GitStatusOpen,
|
Kind::GitStatusOpen,
|
||||||
Kind::GitStatusApplied,
|
Kind::GitStatusApplied,
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Git root kinds that make a comment or cover note count as git activity.
|
/// Git root kinds that make a comment count as git activity.
|
||||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -38,6 +37,15 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
|||||||
Kind::GitRepoAnnouncement,
|
Kind::GitRepoAnnouncement,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
||||||
|
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||||
|
kind == Kind::GitRepoAnnouncement
|
||||||
|
|| kind == Kind::RepoState
|
||||||
|
|| kind == Kind::EventDeletion
|
||||||
|
|| kind == Kind::RequestToVanish
|
||||||
|
|| ACTIVITY_KINDS.contains(&kind)
|
||||||
|
}
|
||||||
|
|
||||||
/// Value of the first tag named `name` on `event`.
|
/// Value of the first tag named `name` on `event`.
|
||||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||||
event
|
event
|
||||||
@@ -91,18 +99,6 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
|||||||
.events(roots)
|
.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`.
|
/// A user's grasp list, kind `10317`.
|
||||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
@@ -152,13 +148,7 @@ pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
|||||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||||
/// through [`is_git_activity`] before display.
|
/// through [`is_git_activity`] before display.
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||||
.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.
|
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||||
@@ -167,12 +157,6 @@ fn is_git_comment(event: &Event) -> bool {
|
|||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
&& 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.
|
/// Whether a status event references a git root, checked via its `k` tag.
|
||||||
fn is_git_status(event: &Event) -> bool {
|
fn is_git_status(event: &Event) -> bool {
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
@@ -187,7 +171,7 @@ pub fn is_git_activity(event: &Event) -> bool {
|
|||||||
| Kind::GitStatusApplied
|
| Kind::GitStatusApplied
|
||||||
| Kind::GitStatusClosed
|
| Kind::GitStatusClosed
|
||||||
| Kind::GitStatusDraft => is_git_status(event),
|
| Kind::GitStatusDraft => is_git_status(event),
|
||||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +180,11 @@ pub fn all_announcements() -> Filter {
|
|||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All repository state events, carrying each repository's refs and last push time.
|
||||||
|
pub fn all_states() -> Filter {
|
||||||
|
Filter::new().kind(Kind::RepoState)
|
||||||
|
}
|
||||||
|
|
||||||
/// How far back deletion requests are fetched and stored.
|
/// How far back deletion requests are fetched and stored.
|
||||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
|
|
||||||
@@ -274,17 +263,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let status = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||||
);
|
);
|
||||||
let cover = signed(
|
|
||||||
&keys(1),
|
|
||||||
COVER_NOTE_KIND,
|
|
||||||
vec![kind_tag("k", Kind::GitPatch)],
|
|
||||||
);
|
|
||||||
let unrelated = signed(
|
let unrelated = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
@@ -292,7 +276,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_git_activity(&status));
|
assert!(is_git_activity(&status));
|
||||||
assert!(is_git_activity(&cover));
|
|
||||||
assert!(!is_git_activity(&unrelated));
|
assert!(!is_git_activity(&unrelated));
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!is_git_activity(&signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
use crate::{RepoAddr, activity_subject};
|
||||||
|
|
||||||
/// Window before `now` that an advanced cutoff retreats to.
|
/// Window before `now` that an advanced cutoff retreats to.
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||||
@@ -19,8 +19,6 @@ pub struct InboxItem {
|
|||||||
pub root: EventId,
|
pub root: EventId,
|
||||||
/// The root event itself, when it is known locally.
|
/// The root event itself, when it is known locally.
|
||||||
pub root_event: Option<Event>,
|
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.
|
/// Repository the root belongs to, from the root's `a` tag.
|
||||||
pub address: Option<RepoAddr>,
|
pub address: Option<RepoAddr>,
|
||||||
/// Notification events directed at the user, newest first.
|
/// Notification events directed at the user, newest first.
|
||||||
@@ -45,13 +43,11 @@ impl InboxItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn kind(&self) -> Option<Kind> {
|
pub fn kind(&self) -> Option<Kind> {
|
||||||
self.root_kind.or_else(|| {
|
self.root_event
|
||||||
self.root_event
|
.as_ref()
|
||||||
.as_ref()
|
.or_else(|| self.own_events.first())
|
||||||
.or_else(|| self.own_events.first())
|
.or_else(|| self.events.first())
|
||||||
.or_else(|| self.events.first())
|
.map(|event| event.kind)
|
||||||
.map(|event| event.kind)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Timestamp of the newest event in the thread.
|
/// Timestamp of the newest event in the thread.
|
||||||
@@ -125,14 +121,11 @@ impl InboxItem {
|
|||||||
/// - patch (1617): its `e` parent patch, else itself
|
/// - patch (1617): its `e` parent patch, else itself
|
||||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
/// - PR update (1619): uppercase `E`
|
/// - PR update (1619): uppercase `E`
|
||||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
/// - statuses (1630-1633): NIP-10 root `e`
|
||||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||||
where
|
where
|
||||||
L: Fn(EventId) -> Option<Event>,
|
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 {
|
match event.kind {
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
Kind::GitPatch => Some(match first_e_id(event) {
|
Kind::GitPatch => Some(match first_e_id(event) {
|
||||||
@@ -202,7 +195,6 @@ where
|
|||||||
|
|
||||||
let mut item = InboxItem {
|
let mut item = InboxItem {
|
||||||
root,
|
root,
|
||||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
|
||||||
address: root_event
|
address: root_event
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|event| event.tags.coordinates().next()),
|
.and_then(|event| event.tags.coordinates().next()),
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
pub mod annotations;
|
|
||||||
pub mod clone_url;
|
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
@@ -9,8 +7,6 @@ pub mod state;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
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 deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use filters::{
|
pub use filters::{
|
||||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||||
|
|||||||
@@ -41,12 +41,10 @@ pub struct Upstream {
|
|||||||
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
||||||
/// `None` for the git-URL form.
|
/// `None` for the git-URL form.
|
||||||
pub addr: Option<RepoAddr>,
|
pub addr: Option<RepoAddr>,
|
||||||
/// Relay hint for the upstream, if the `u` tag carries one.
|
|
||||||
pub relay_hint: Option<RelayUrl>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Upstream {
|
impl Upstream {
|
||||||
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
fn parse(raw: &str) -> Self {
|
||||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||||
let addr = coordinate
|
let addr = coordinate
|
||||||
.parse::<Coordinate>()
|
.parse::<Coordinate>()
|
||||||
@@ -55,7 +53,6 @@ impl Upstream {
|
|||||||
Self {
|
Self {
|
||||||
raw: raw.to_owned(),
|
raw: raw.to_owned(),
|
||||||
addr,
|
addr,
|
||||||
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +314,7 @@ impl Announcement {
|
|||||||
let values = tag.as_slice();
|
let values = tag.as_slice();
|
||||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||||
if !raw.is_empty() {
|
if !raw.is_empty() {
|
||||||
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
|
upstream = Some(Upstream::parse(raw));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,10 +508,6 @@ mod tests {
|
|||||||
upstream.raw,
|
upstream.raw,
|
||||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
|
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
upstream.relay_hint,
|
|
||||||
Some(RelayUrl::parse("wss://relay.example.com").expect("valid relay"))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
upstream.display().to_string(),
|
upstream.display().to_string(),
|
||||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
|
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ publish.workspace = true
|
|||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||||
gix-worktree = "0.56"
|
gix-worktree = "0.56"
|
||||||
gix-worktree-state = "0.34"
|
gix-worktree-state = "0.34"
|
||||||
|
|||||||
@@ -72,17 +72,6 @@ fn file_commit_with_description(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||||
/// `rels` are paths relative to the worktree.
|
/// `rels` are paths relative to the worktree.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod cache;
|
mod cache;
|
||||||
mod diff;
|
mod diff;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod nip34;
|
||||||
mod patch;
|
mod patch;
|
||||||
mod remote;
|
mod remote;
|
||||||
mod repo;
|
mod repo;
|
||||||
@@ -16,8 +17,11 @@ pub use diff::{
|
|||||||
worktree_commit_range_diff,
|
worktree_commit_range_diff,
|
||||||
};
|
};
|
||||||
pub use history::{
|
pub use history::{
|
||||||
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit,
|
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits,
|
||||||
worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
||||||
|
};
|
||||||
|
pub use nip34::{
|
||||||
|
GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo,
|
||||||
};
|
};
|
||||||
pub use patch::{
|
pub use patch::{
|
||||||
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
|
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
|
||||||
@@ -32,7 +36,7 @@ pub use repo::{
|
|||||||
repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists,
|
repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists,
|
||||||
worktree_ref_state,
|
worktree_ref_state,
|
||||||
};
|
};
|
||||||
pub use scan::find_git_repos;
|
pub use scan::{LocalRepo, find_git_repos};
|
||||||
pub use worktree::{
|
pub use worktree::{
|
||||||
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag,
|
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag,
|
||||||
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
|
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use gix::bstr::ByteSlice;
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Nip34Kind {
|
||||||
|
/// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||||
|
Initialized,
|
||||||
|
/// Cloned from a `nostr://` remote but never initialized locally.
|
||||||
|
Cloned,
|
||||||
|
/// Nostr tooling touched the repository but no binding is recoverable.
|
||||||
|
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,
|
||||||
|
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
|
||||||
|
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 {
|
||||||
|
pub fn any(&self) -> bool {
|
||||||
|
*self != Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a local repository's on-disk state says about its NIP-34 binding.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Nip34Binding {
|
||||||
|
pub kind: Nip34Kind,
|
||||||
|
pub signals: GraspSignals,
|
||||||
|
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
|
||||||
|
pub owner: Option<PublicKey>,
|
||||||
|
pub identifier: Option<String>,
|
||||||
|
pub grasp_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Nip34Json {
|
||||||
|
identifier: Option<String>,
|
||||||
|
owner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> {
|
||||||
|
let repo = gix::open(repo_path).ok()?;
|
||||||
|
let common_dir = repo.common_dir().to_path_buf();
|
||||||
|
let workdir = repo.workdir().map(Path::to_path_buf);
|
||||||
|
|
||||||
|
let mut signals = GraspSignals::default();
|
||||||
|
let mut owner: Option<PublicKey> = None;
|
||||||
|
let mut identifier: Option<String> = None;
|
||||||
|
let mut grasp_urls: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(workdir) = &workdir {
|
||||||
|
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
||||||
|
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
||||||
|
{
|
||||||
|
signals.nip34_json = true;
|
||||||
|
identifier = config.identifier.and_then(non_empty);
|
||||||
|
owner = config
|
||||||
|
.owner
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| PublicKey::parse(value).ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
if workdir.join("maintainers.yaml").is_file() {
|
||||||
|
signals.maintainers_yaml = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
||||||
|
&& exclude.contains("nip34.json")
|
||||||
|
{
|
||||||
|
signals.nip34_excluded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ngit` keeps its repository event cache in the Git common directory.
|
||||||
|
if common_dir.join("nostr-cache.lmdb").is_file() {
|
||||||
|
signals.nostr_cache = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
||||||
|
if let Ok(config) = gix::config::File::from_path_no_includes(
|
||||||
|
common_dir.join("config"),
|
||||||
|
gix::config::Source::Local,
|
||||||
|
) {
|
||||||
|
if let Some(value) = config.string("nostr.repo")
|
||||||
|
&& let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy())
|
||||||
|
{
|
||||||
|
signals.nostr_repo_config = true;
|
||||||
|
owner = Some(key);
|
||||||
|
identifier = Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
||||||
|
if config.string(key).is_some() {
|
||||||
|
signals.nostr_aux_config = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sections) = config.sections_by_name("remote") {
|
||||||
|
for section in sections {
|
||||||
|
let Some(name) = section.header().subsection_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
|
||||||
|
|
||||||
|
for url in section.values("url") {
|
||||||
|
let url = url.to_str_lossy();
|
||||||
|
|
||||||
|
if url.starts_with("nostr://") {
|
||||||
|
signals.nostr_remote = true;
|
||||||
|
// Strong markers win; only fill an empty binding.
|
||||||
|
if owner.is_none()
|
||||||
|
&& identifier.is_none()
|
||||||
|
&& let Some((key, id)) = parse_nostr_url(&url)
|
||||||
|
{
|
||||||
|
owner = Some(key);
|
||||||
|
identifier = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_grasp_url(&url) {
|
||||||
|
signals.grasp_remote = true;
|
||||||
|
signals.nip34_grasp_remote |= nak_grasp_remote;
|
||||||
|
grasp_urls.push(url.to_string());
|
||||||
|
|
||||||
|
if owner.is_none()
|
||||||
|
&& identifier.is_none()
|
||||||
|
&& let Some((key, id)) = grasp_parts(&url)
|
||||||
|
{
|
||||||
|
owner = Some(key);
|
||||||
|
identifier = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
||||||
|
if let Ok(platform) = repo.references()
|
||||||
|
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
||||||
|
&& refs.next().is_some()
|
||||||
|
{
|
||||||
|
signals.nip34_state_refs = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !signals.any() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = if signals.nip34_json
|
||||||
|
|| signals.nostr_repo_config
|
||||||
|
|| signals.nip34_grasp_remote
|
||||||
|
|| signals.nip34_state_refs
|
||||||
|
{
|
||||||
|
Nip34Kind::Initialized
|
||||||
|
} else if signals.nostr_remote {
|
||||||
|
Nip34Kind::Cloned
|
||||||
|
} else {
|
||||||
|
Nip34Kind::ToolingOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Nip34Binding {
|
||||||
|
kind,
|
||||||
|
signals,
|
||||||
|
owner,
|
||||||
|
identifier,
|
||||||
|
grasp_urls,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config.
|
||||||
|
pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> {
|
||||||
|
let repo = gix::open(repo_path)?;
|
||||||
|
|
||||||
|
crate::remote::edit_local_config(&repo, |config| {
|
||||||
|
config.set_raw_value("nostr.repo", naddr)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes,
|
||||||
|
/// and a first segment that decodes as an `npub`.
|
||||||
|
pub fn is_grasp_url(url: &str) -> bool {
|
||||||
|
let Ok(parsed) = Url::parse(url) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = parsed.path();
|
||||||
|
if path.matches('/').count() != 2 || path.len() < 65 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
grasp_parts(url).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
|
||||||
|
let parsed = Url::parse(url).ok()?;
|
||||||
|
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
|
||||||
|
|
||||||
|
let owner = PublicKey::parse(segments.next()?).ok()?;
|
||||||
|
let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?;
|
||||||
|
|
||||||
|
Some((owner, identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
|
||||||
|
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
|
||||||
|
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let identifier = non_empty(coordinate.identifier.clone())?;
|
||||||
|
Some((coordinate.public_key, identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||||
|
/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||||
|
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
||||||
|
let rest = url.strip_prefix("nostr://")?;
|
||||||
|
|
||||||
|
if rest.starts_with("naddr1") {
|
||||||
|
return coordinate_from_naddr(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
||||||
|
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
||||||
|
|
||||||
|
if parts
|
||||||
|
.first()
|
||||||
|
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
||||||
|
{
|
||||||
|
parts.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `[owner, (relay), identifier]`.
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner = PublicKey::parse(parts[0]).ok()?;
|
||||||
|
let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?;
|
||||||
|
|
||||||
|
Some((owner, identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: impl Into<String>) -> Option<String> {
|
||||||
|
let value = value.into();
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("repo");
|
||||||
|
std::fs::create_dir_all(&path).expect("mkdir");
|
||||||
|
git(&path, &["init", "-q"]);
|
||||||
|
(dir, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(dir: &Path, args: &[&str]) {
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(dir)
|
||||||
|
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||||
|
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.expect("spawn git");
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key() -> PublicKey {
|
||||||
|
Keys::generate().public_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
|
||||||
|
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
|
||||||
|
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
|
||||||
|
.to_bech32()
|
||||||
|
.expect("naddr")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_repository_has_no_binding() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
assert!(detect_nip34(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nip34_json_marks_a_repository_initialized() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
let owner = key();
|
||||||
|
let npub = owner.to_bech32().expect("npub");
|
||||||
|
std::fs::write(
|
||||||
|
path.join("nip34.json"),
|
||||||
|
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
|
assert!(binding.signals.nip34_json);
|
||||||
|
assert_eq!(binding.owner, Some(owner));
|
||||||
|
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_nip34_json_is_ignored() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
|
||||||
|
|
||||||
|
assert!(detect_nip34(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nak_exclude_and_state_refs_are_detected() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
|
||||||
|
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
|
||||||
|
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
|
||||||
|
|
||||||
|
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
|
||||||
|
git(
|
||||||
|
&path,
|
||||||
|
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
|
||||||
|
);
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
|
assert!(binding.signals.nip34_excluded);
|
||||||
|
assert!(binding.signals.nip34_state_refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nostr_repo_config_marks_a_repository_initialized() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
let owner = key();
|
||||||
|
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||||
|
git(&path, &["config", "nostr.repo", &naddr]);
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
|
assert!(binding.signals.nostr_repo_config);
|
||||||
|
assert_eq!(binding.owner, Some(owner));
|
||||||
|
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_written_nostr_repo_marker_is_detected() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
let owner = key();
|
||||||
|
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||||
|
|
||||||
|
set_nostr_repo(&path, &naddr).expect("write marker");
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
|
assert!(binding.signals.nostr_repo_config);
|
||||||
|
assert_eq!(binding.owner, Some(owner));
|
||||||
|
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nostr_remote_is_a_nip34_clone() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
let owner = key();
|
||||||
|
let npub = owner.to_bech32().expect("npub");
|
||||||
|
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
|
||||||
|
git(&path, &["remote", "add", "origin", &url]);
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::Cloned);
|
||||||
|
assert!(binding.signals.nostr_remote);
|
||||||
|
assert_eq!(binding.owner, Some(owner));
|
||||||
|
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nak_grasp_remote_marks_a_repository_initialized() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
let owner = key();
|
||||||
|
let npub = owner.to_bech32().expect("npub");
|
||||||
|
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
|
||||||
|
git(
|
||||||
|
&path,
|
||||||
|
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
|
||||||
|
);
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||||
|
assert!(binding.signals.nip34_grasp_remote);
|
||||||
|
assert!(binding.signals.grasp_remote);
|
||||||
|
assert_eq!(binding.grasp_urls, vec![url]);
|
||||||
|
assert_eq!(binding.owner, Some(owner));
|
||||||
|
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nostr_cache_alone_is_tooling_only() {
|
||||||
|
let (_dir, path) = init_repo();
|
||||||
|
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
|
||||||
|
|
||||||
|
let binding = detect_nip34(&path).expect("binding");
|
||||||
|
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
|
||||||
|
assert!(binding.signals.nostr_cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grasp_urls_are_recognised_by_shape() {
|
||||||
|
let owner = key();
|
||||||
|
let npub = owner.to_bech32().expect("npub");
|
||||||
|
|
||||||
|
assert!(is_grasp_url(&format!(
|
||||||
|
"https://gitnostr.com/{npub}/my-repo.git"
|
||||||
|
)));
|
||||||
|
assert!(is_grasp_url(&format!(
|
||||||
|
"grasp://gitnostr.com/{npub}/my-repo.git"
|
||||||
|
)));
|
||||||
|
|
||||||
|
assert!(!is_grasp_url("https://gitnostr.com/my-repo.git"));
|
||||||
|
assert!(!is_grasp_url(
|
||||||
|
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
||||||
|
));
|
||||||
|
assert!(!is_grasp_url(&format!(
|
||||||
|
"ssh://gitnostr.com/{npub}/my-repo.git"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,10 +252,7 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply `edit` to the repository-local configuration and persist it.
|
/// Apply `edit` to the repository-local configuration and persist it.
|
||||||
///
|
pub(crate) fn edit_local_config(
|
||||||
/// 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,
|
repo: &gix::Repository,
|
||||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use crate::history::open_with_cache;
|
use crate::history::open_with_cache;
|
||||||
use crate::worktree::{force_checkout, worktree_dirty};
|
use crate::worktree::{force_checkout, worktree_dirty};
|
||||||
@@ -171,12 +171,7 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
|
|||||||
let mut index = repo.index_from_tree(&tree)?;
|
let mut index = repo.index_from_tree(&tree)?;
|
||||||
index.write(gix::index::write::Options::default())?;
|
index.write(gix::index::write::Options::default())?;
|
||||||
|
|
||||||
let commit = commit.to_string();
|
Ok(commit.to_string())
|
||||||
if commit.len() != 40 {
|
|
||||||
bail!("unexpected initial commit id: {commit}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(commit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The earliest unique commit of the repository at `repo_path`.
|
/// The earliest unique commit of the repository at `repo_path`.
|
||||||
@@ -201,8 +196,7 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
|||||||
{
|
{
|
||||||
let info = info?;
|
let info = info?;
|
||||||
if info.parent_ids().next().is_none() {
|
if info.parent_ids().next().is_none() {
|
||||||
let id = info.id().to_string();
|
return Ok(Some(info.id().to_string()));
|
||||||
return Ok((id.len() == 40).then_some(id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
|
use crate::nip34::{Nip34Binding, detect_nip34};
|
||||||
|
|
||||||
/// Caps nesting so pathological trees can't stall the scan.
|
/// Caps nesting so pathological trees can't stall the scan.
|
||||||
const SCAN_MAX_DEPTH: usize = 12;
|
const SCAN_MAX_DEPTH: usize = 12;
|
||||||
|
|
||||||
/// Walk `root` recursively and collect the paths of git repositories below it.
|
/// A git repository discovered under a scan root.
|
||||||
/// `.gitignore` and `.ignore` files are honoured.
|
#[derive(Debug, Clone)]
|
||||||
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
pub struct LocalRepo {
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// `None` for a plain repository.
|
||||||
|
pub nip34: Option<Nip34Binding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk `root` recursively and collect the git repositories below it.
|
||||||
|
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -38,4 +47,10 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
roots
|
roots
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let nip34 = detect_nip34(&path);
|
||||||
|
LocalRepo { path, nip34 }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -42,7 +42,10 @@ fn find_git_repos_discovers_repositories_recursively() {
|
|||||||
std::fs::create_dir_all(outer.join(".git")).unwrap();
|
std::fs::create_dir_all(outer.join(".git")).unwrap();
|
||||||
std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
|
std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
|
||||||
|
|
||||||
let mut found = find_git_repos(root);
|
let mut found: Vec<PathBuf> = find_git_repos(root)
|
||||||
|
.into_iter()
|
||||||
|
.map(|repo| repo.path)
|
||||||
|
.collect();
|
||||||
found.sort();
|
found.sort();
|
||||||
|
|
||||||
let mut expected = vec![
|
let mut expected = vec![
|
||||||
@@ -578,7 +581,7 @@ fn git_run(dir: &Path, args: &[&str]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn last_commit_returns_most_recent_change() {
|
fn worktree_last_commits_returns_most_recent_change() {
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
commit_all(&repo, "initial");
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
@@ -589,9 +592,12 @@ fn last_commit_returns_most_recent_change() {
|
|||||||
std::fs::write(dir.path().join("b.txt"), b"other").expect("write");
|
std::fs::write(dir.path().join("b.txt"), b"other").expect("write");
|
||||||
commit_all(&repo, "add b");
|
commit_all(&repo, "add b");
|
||||||
|
|
||||||
let commit = last_commit(&repo, Path::new("a.txt"))
|
let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")])
|
||||||
.expect("lookup")
|
.expect("lookup")
|
||||||
.expect("found");
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("found")
|
||||||
|
.1;
|
||||||
assert_eq!(commit.summary, "change a");
|
assert_eq!(commit.summary, "change a");
|
||||||
assert_eq!(commit.author, "Test Author");
|
assert_eq!(commit.author, "Test Author");
|
||||||
assert!(!commit.id.is_empty());
|
assert!(!commit.id.is_empty());
|
||||||
@@ -621,7 +627,7 @@ fn all_commits_lists_every_commit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn last_commit_reports_merge_commits() {
|
fn worktree_last_commits_reports_merge_commits() {
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"base")]);
|
let (dir, repo) = fixture(&[("a.txt", b"base")]);
|
||||||
commit_all(&repo, "initial");
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
@@ -645,9 +651,12 @@ fn last_commit_reports_merge_commits() {
|
|||||||
// `--no-ff` forces a merge commit, it is the latest commit changing a.txt.
|
// `--no-ff` forces a merge commit, it is the latest commit changing a.txt.
|
||||||
run(&["merge", "--no-ff", "--no-edit", "feature"]);
|
run(&["merge", "--no-ff", "--no-edit", "feature"]);
|
||||||
|
|
||||||
let commit = last_commit(&repo, Path::new("a.txt"))
|
let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")])
|
||||||
.expect("lookup")
|
.expect("lookup")
|
||||||
.expect("found");
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("found")
|
||||||
|
.1;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
commit.id,
|
commit.id,
|
||||||
repo.head_id().expect("head").shorten_or_id().to_string()
|
repo.head_id().expect("head").shorten_or_id().to_string()
|
||||||
|
|||||||
@@ -64,20 +64,10 @@ pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
|||||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve `rev` to a commit id, accepting full refs,
|
/// Resolve `rev` to a commit id, accepting full refs or the bare branch names
|
||||||
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
|
/// callers pass. `gix`'s revision parser already applies git's ref DWIM.
|
||||||
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
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()) {
|
repo.rev_parse_single(rev.as_bytes()).ok()
|
||||||
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.
|
/// Relative paths of all entries in the worktree, files and directories.
|
||||||
|
|||||||
@@ -5,32 +5,9 @@ use std::pin::Pin;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use nostr_connect::client::AuthUrlHandler;
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
|
use nostr_sdk::error::Error as SignerError;
|
||||||
use nostr_sdk::prelude::*;
|
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.
|
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
@@ -65,21 +42,21 @@ impl UniversalSigner {
|
|||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
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>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
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>(
|
fn nip44_decrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -94,22 +71,22 @@ where
|
|||||||
{
|
{
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +94,11 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
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 {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,17 +106,17 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
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 {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncGetPublicKey for UniversalSigner {
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -150,7 +127,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncSignEvent for UniversalSigner {
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -162,7 +139,7 @@ impl AsyncSignEvent for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncNip44 for UniversalSigner {
|
impl AsyncNip44 for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ nostr-connect.workspace = true
|
|||||||
|
|
||||||
bitcoin_hashes = "1"
|
bitcoin_hashes = "1"
|
||||||
|
|
||||||
|
gix.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|||||||
+198
-174
@@ -5,30 +5,24 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::{Error, anyhow, bail};
|
use anyhow::{Error, anyhow, bail};
|
||||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Task};
|
||||||
use nostr::event::IntoEventBuilder;
|
use nostr::event::IntoEventBuilder;
|
||||||
|
use nostr::nips::nip19::Nip19Coordinate;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::client::SyncSummary;
|
use nostr_sdk::client::SyncSummary;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name};
|
use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr};
|
||||||
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::repo_mirror_path;
|
||||||
use crate::inbox::Inbox;
|
use crate::inbox::Inbox;
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
/// Timeout for NIP-46 signer responses.
|
/// Timeout for NIP-46 signer responses.
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Relays connected at startup, before any user-specific relay config is known.
|
/// Relays connected at startup, before any user-specific relay config is known.
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://index.ngit.dev",
|
|
||||||
"wss://profiles.nostr1.com",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Relays used to index the user's NIP-65 relay list.
|
/// Relays used to index the user's NIP-65 relay list.
|
||||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
|
|
||||||
@@ -41,20 +35,15 @@ pub enum BackendEvent {
|
|||||||
PassphraseRequired,
|
PassphraseRequired,
|
||||||
/// The signer changed on login, logout or account switch.
|
/// The signer changed on login, logout or account switch.
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
/// New events were received from a relay and stored in the database.
|
/// Kind-0 metadata arrived for these authors; re-read them from the store.
|
||||||
///
|
ProfileUpdates(Vec<PublicKey>),
|
||||||
/// Batched: [`Backend`]'s notification pump coalesces everything a
|
/// Repository events arrived: announcements, states, activity and deletions.
|
||||||
/// relay delivers within one debounce window into a single event,
|
RepoUpdates(Vec<Update>),
|
||||||
/// instead of emitting per-event and making every subscriber debounce
|
|
||||||
/// the same burst independently.
|
|
||||||
NostrUpdate(Vec<Update>),
|
|
||||||
Synced,
|
Synced,
|
||||||
SyncProgress {
|
SyncProgress {
|
||||||
total: u64,
|
total: u64,
|
||||||
current: u64,
|
current: u64,
|
||||||
},
|
},
|
||||||
/// An event built locally was signed, broadcast and stored.
|
|
||||||
Published(Box<Event>),
|
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +88,16 @@ impl Backend {
|
|||||||
|
|
||||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let mut notifications = pump_client.notifications();
|
let mut notifications = pump_client.notifications();
|
||||||
let mut pending: Vec<Update> = Vec::new();
|
let mut pending_profiles: HashSet<PublicKey> = HashSet::new();
|
||||||
|
let mut pending_repos: Vec<Update> = Vec::new();
|
||||||
|
let mut seen: HashSet<EventId> = HashSet::new();
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
match notifications.next().await {
|
match next_update(&mut notifications, &mut seen).await {
|
||||||
Some(ClientNotification::Event { event, .. }) => {
|
Some(UpdateEvent::Profile(author)) => {
|
||||||
pending.push(Update::from_event(&event));
|
pending_profiles.insert(author);
|
||||||
}
|
}
|
||||||
Some(_) => continue,
|
Some(UpdateEvent::Repo(update)) => pending_repos.push(update),
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,28 +113,33 @@ impl Backend {
|
|||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let next = notifications.next();
|
let next = next_update(&mut notifications, &mut seen);
|
||||||
futures::pin_mut!(next);
|
futures::pin_mut!(next);
|
||||||
|
|
||||||
match futures::future::select(next, timer).await {
|
match futures::future::select(next, timer).await {
|
||||||
futures::future::Either::Left((
|
futures::future::Either::Left((Some(UpdateEvent::Profile(author)), _)) => {
|
||||||
Some(ClientNotification::Event { event, .. }),
|
pending_profiles.insert(author);
|
||||||
_,
|
}
|
||||||
)) => {
|
futures::future::Either::Left((Some(UpdateEvent::Repo(update)), _)) => {
|
||||||
pending.push(Update::from_event(&event));
|
pending_repos.push(update);
|
||||||
}
|
}
|
||||||
futures::future::Either::Left((Some(_), _)) => continue,
|
|
||||||
futures::future::Either::Left((None, _)) => break 'outer,
|
futures::future::Either::Left((None, _)) => break 'outer,
|
||||||
futures::future::Either::Right(_) => break,
|
futures::future::Either::Right(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let batch = std::mem::take(&mut pending);
|
let profiles: Vec<PublicKey> = pending_profiles.drain().collect();
|
||||||
|
let repos = std::mem::take(&mut pending_repos);
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) = this.update(cx, |_this, cx| {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
|
if !profiles.is_empty() {
|
||||||
{
|
cx.emit(BackendEvent::ProfileUpdates(profiles));
|
||||||
log::warn!("failed to emit nostr update: {e}");
|
}
|
||||||
|
if !repos.is_empty() {
|
||||||
|
cx.emit(BackendEvent::RepoUpdates(repos));
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to emit backend update: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +149,10 @@ impl Backend {
|
|||||||
pump.detach();
|
pump.detach();
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
weak.update(cx, |this, cx| {
|
||||||
log::warn!("backend dropped before bootstrap could run: {error}");
|
this.restore_session(cx);
|
||||||
}
|
})
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -169,42 +166,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let client = self.client.clone();
|
|
||||||
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
for url in BOOTSTRAP_RELAYS {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
|
||||||
client
|
|
||||||
.add_relay(url)
|
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.connect().await;
|
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(()) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.restore_session(cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
});
|
|
||||||
notify_task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore the saved session from the Keyring.
|
/// Restore the saved session from the Keyring.
|
||||||
///
|
///
|
||||||
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||||
@@ -229,7 +190,9 @@ impl Backend {
|
|||||||
let result = async {
|
let result = async {
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
} else if content.starts_with("bunker://") {
|
} else if content.starts_with("bunker://") {
|
||||||
let (base, keys) = extract_master_key(&content);
|
let (base, keys) = extract_master_key(&content);
|
||||||
let uri = NostrConnectUri::parse(base)?;
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
@@ -240,14 +203,19 @@ impl Backend {
|
|||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
} else if content.starts_with("ncryptsec1") {
|
} else if content.starts_with("ncryptsec1") {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
cx.emit(BackendEvent::PassphraseRequired);
|
cx.emit(BackendEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(BackendEvent::SignerRequired);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -445,9 +413,6 @@ impl Backend {
|
|||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
// Initialize directly at the user's chosen destination.
|
// Initialize directly at the user's chosen destination.
|
||||||
// No mirror is pre-populated: `GitCache::ensure_clone` lazily clones
|
|
||||||
// from the grasp server the first time the repo detail view needs it,
|
|
||||||
// exactly like every other repository.
|
|
||||||
let destination = {
|
let destination = {
|
||||||
let dir_name = signed_git::sanitize_path_component(&name);
|
let dir_name = signed_git::sanitize_path_component(&name);
|
||||||
let dir_name = if dir_name.is_empty() {
|
let dir_name = if dir_name.is_empty() {
|
||||||
@@ -512,9 +477,7 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
require_relay_accepted(output, event)?
|
||||||
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
|
||||||
event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The state event is the push authorization. Stage it on each
|
// The state event is the push authorization. Stage it on each
|
||||||
@@ -531,6 +494,7 @@ impl Backend {
|
|||||||
let repo_id = repo_id.clone();
|
let repo_id = repo_id.clone();
|
||||||
let servers = servers.clone();
|
let servers = servers.clone();
|
||||||
let refs = refs.clone();
|
let refs = refs.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
async move {
|
async move {
|
||||||
push_staged_to_grasps(
|
push_staged_to_grasps(
|
||||||
&client,
|
&client,
|
||||||
@@ -541,6 +505,7 @@ impl Backend {
|
|||||||
&destination,
|
&destination,
|
||||||
&owner,
|
&owner,
|
||||||
&servers,
|
&servers,
|
||||||
|
&executor,
|
||||||
signed_git::push_main,
|
signed_git::push_main,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -567,14 +532,10 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it
|
// Staging already stored the event locally, publishing makes it
|
||||||
// visible to the other relays and clients.
|
// visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
{
|
||||||
}
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.announce_published(state_event.clone(), cx)
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event)
|
let announcement = Announcement::from_event(&event)
|
||||||
@@ -663,9 +624,7 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
require_relay_accepted(output, event)?
|
||||||
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
|
||||||
event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let refs = state.refs.clone();
|
let refs = state.refs.clone();
|
||||||
@@ -686,6 +645,7 @@ impl Backend {
|
|||||||
let servers = servers.clone();
|
let servers = servers.clone();
|
||||||
let refs = refs.clone();
|
let refs = refs.clone();
|
||||||
let head = head.clone();
|
let head = head.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
async move {
|
async move {
|
||||||
push_staged_to_grasps(
|
push_staged_to_grasps(
|
||||||
&client,
|
&client,
|
||||||
@@ -696,6 +656,7 @@ impl Backend {
|
|||||||
&path,
|
&path,
|
||||||
&owner,
|
&owner,
|
||||||
&servers,
|
&servers,
|
||||||
|
&executor,
|
||||||
signed_git::push_all,
|
signed_git::push_all,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -720,12 +681,10 @@ impl Backend {
|
|||||||
|
|
||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
{
|
||||||
}
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx))
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,6 +698,25 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the ngit-compatible `nostr.repo` marker,
|
||||||
|
// so the next scan detects the repository instead of offering to publish it again.
|
||||||
|
let coordinate = repo_addr(event.pubkey, repo_id.clone());
|
||||||
|
match Nip19Coordinate::new(coordinate, servers.clone()).to_bech32() {
|
||||||
|
Ok(naddr) => {
|
||||||
|
let path = path.clone();
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
if let Err(error) = signed_git::set_nostr_repo(&path, &naddr) {
|
||||||
|
log::warn!(
|
||||||
|
"failed to record the NIP-34 marker for {}: {error}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(error) => log::warn!("failed to encode the repository coordinate: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
|
Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -752,8 +730,7 @@ impl Backend {
|
|||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
let path = repo_mirror_path(&announcement.addr());
|
||||||
let path = cache.repo_path(&announcement.addr());
|
|
||||||
self.push_repo_from(announcement, path, None, cx)
|
self.push_repo_from(announcement, path, None, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,6 +831,7 @@ impl Backend {
|
|||||||
let relays = relays.clone();
|
let relays = relays.clone();
|
||||||
let refs = refs.clone();
|
let refs = refs.clone();
|
||||||
let head = head.clone();
|
let head = head.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
async move {
|
async move {
|
||||||
push_staged_to_grasps(
|
push_staged_to_grasps(
|
||||||
&client,
|
&client,
|
||||||
@@ -864,6 +842,7 @@ impl Backend {
|
|||||||
&path,
|
&path,
|
||||||
&owner,
|
&owner,
|
||||||
&relays,
|
&relays,
|
||||||
|
&executor,
|
||||||
signed_git::push_all,
|
signed_git::push_all,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -882,14 +861,10 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing notifies
|
// Staging already stored the event locally, publishing notifies
|
||||||
// the repository views and other relays and clients.
|
// the repository views and other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event {
|
if let Some(state_event) = &outcome.state_event
|
||||||
if let Err(e) = client.send_event(state_event).broadcast().await {
|
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
{
|
||||||
}
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.announce_published(state_event.clone(), cx)
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
@@ -1061,7 +1036,7 @@ impl Backend {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for url in user_grasp_list_servers(client.clone(), public_key).await? {
|
for url in user_grasp_list_servers(&client, public_key).await? {
|
||||||
client.add_relay(url).and_connect().await.ok();
|
client.add_relay(url).and_connect().await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,10 +1061,6 @@ impl Backend {
|
|||||||
self.signer.clone()
|
self.signer.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
|
|
||||||
self.pushing_repos.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inbox(&self) -> Entity<Inbox> {
|
pub fn inbox(&self) -> Entity<Inbox> {
|
||||||
self.inbox.clone()
|
self.inbox.clone()
|
||||||
}
|
}
|
||||||
@@ -1102,10 +1073,6 @@ impl Backend {
|
|||||||
self.passphrase_required
|
self.passphrase_required
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
|
||||||
cx.emit(BackendEvent::error(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let me = self.current_user;
|
let me = self.current_user;
|
||||||
@@ -1188,6 +1155,21 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync filters through the SDK's NIP-65 gossip targeting.
|
||||||
|
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |_this, _cx| {
|
||||||
|
for filter in filters {
|
||||||
|
if let Err(e) = client.sync(filter).await {
|
||||||
|
log::warn!("gossip relay fetch failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1205,14 +1187,15 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
/// Sync several bootstrap filters in order, within a single task.
|
||||||
|
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let (tx, mut rx) = SyncProgress::channel();
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
|
||||||
self.sync_progress = Some((0, 0));
|
self.sync_progress = Some((0, 0));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let mut last_percent: u64 = 0;
|
let mut last_percent: u64 = 0;
|
||||||
|
|
||||||
while rx.changed().await.is_ok() {
|
while rx.changed().await.is_ok() {
|
||||||
@@ -1237,23 +1220,29 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok::<(), anyhow::Error>(())
|
||||||
});
|
})
|
||||||
progress_task.detach();
|
.detach();
|
||||||
|
|
||||||
let sync = cx.background_spawn(async move {
|
let sync = cx.background_spawn(async move {
|
||||||
let opts = SyncOptions::default().progress(tx);
|
let mut first_error = None;
|
||||||
sync_bootstrap_only(&client, filter, opts).await
|
|
||||||
|
for filter in filters {
|
||||||
|
let opts = SyncOptions::default().progress(tx.clone());
|
||||||
|
if let Err(error) = sync_bootstrap_only(&client, filter, opts).await {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match first_error {
|
||||||
|
Some(error) => Err(error),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
match sync.await {
|
match sync.await {
|
||||||
Ok(summary) => {
|
Ok(_) => {
|
||||||
log::debug!(
|
|
||||||
"sync done: {} received, {} sent",
|
|
||||||
summary.received.len(),
|
|
||||||
summary.sent.len()
|
|
||||||
);
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.sync_progress = None;
|
this.sync_progress = None;
|
||||||
cx.emit(BackendEvent::Synced);
|
cx.emit(BackendEvent::Synced);
|
||||||
@@ -1267,17 +1256,10 @@ impl Backend {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
|
Ok::<(), anyhow::Error>(())
|
||||||
///
|
})
|
||||||
/// Callers publish with `client.send_event(...)` directly, then call this
|
.detach();
|
||||||
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
|
||||||
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
|
||||||
cx.emit(BackendEvent::Published(Box::new(event)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
||||||
@@ -1302,6 +1284,42 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A relay event the backend routes to a store group.
|
||||||
|
enum UpdateEvent {
|
||||||
|
Profile(PublicKey),
|
||||||
|
Repo(Update),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await the next relay event from the notification stream.
|
||||||
|
async fn next_update(
|
||||||
|
notifications: &mut (impl futures::Stream<Item = ClientNotification> + Unpin),
|
||||||
|
seen: &mut HashSet<EventId>,
|
||||||
|
) -> Option<UpdateEvent> {
|
||||||
|
loop {
|
||||||
|
match notifications.next().await {
|
||||||
|
Some(ClientNotification::Message { message, .. }) => {
|
||||||
|
let RelayMessage::Event { event, .. } = *message else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = match event.kind {
|
||||||
|
Kind::Metadata => UpdateEvent::Profile(event.pubkey),
|
||||||
|
kind if filters::is_repo_kind(kind) => {
|
||||||
|
UpdateEvent::Repo(Update::from_event(&event))
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if seen.insert(event.id) {
|
||||||
|
return Some(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign and send a single NIP-09 deletion request for `event`.
|
/// Sign and send a single NIP-09 deletion request for `event`.
|
||||||
async fn retract_event(
|
async fn retract_event(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -1311,16 +1329,14 @@ async fn retract_event(
|
|||||||
let builder = EventDeletionRequest::new()
|
let builder = EventDeletionRequest::new()
|
||||||
.id(event.id)
|
.id(event.id)
|
||||||
.into_event_builder();
|
.into_event_builder();
|
||||||
|
|
||||||
let deletion = builder.finalize_async(signer).await?;
|
let deletion = builder.finalize_async(signer).await?;
|
||||||
client.send_event(&deletion).broadcast().await?;
|
client.send_event(&deletion).broadcast().await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
||||||
///
|
|
||||||
/// The SDK does not treat "accepted by zero relays" as an error on its own:
|
|
||||||
/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`.
|
|
||||||
/// This turns that case into an error the caller can surface.
|
|
||||||
pub(crate) fn require_relay_accepted(
|
pub(crate) fn require_relay_accepted(
|
||||||
output: SendEventOutput,
|
output: SendEventOutput,
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -1378,10 +1394,29 @@ async fn connect_repo_relays(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add and connect the startup relays.
|
||||||
|
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(
|
pub(crate) async fn subscribe_bootstrap_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(Duration::from_secs(10)));
|
.timeout(Some(Duration::from_secs(10)));
|
||||||
@@ -1401,11 +1436,14 @@ pub(crate) async fn sync_bootstrap_only(
|
|||||||
filter: Filter,
|
filter: Filter,
|
||||||
opts: SyncOptions,
|
opts: SyncOptions,
|
||||||
) -> Result<SyncSummary, Error> {
|
) -> Result<SyncSummary, Error> {
|
||||||
|
ensure_bootstrap_relays(client).await?;
|
||||||
|
|
||||||
let output = client
|
let output = client
|
||||||
.sync(filter)
|
.sync(filter)
|
||||||
.with(BOOTSTRAP_RELAYS)
|
.with(BOOTSTRAP_RELAYS)
|
||||||
.opts(opts)
|
.opts(opts)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(output.value)
|
Ok(output.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,7 +1516,7 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_grasp_list_servers(
|
pub async fn user_grasp_list_servers(
|
||||||
client: Client,
|
client: &Client,
|
||||||
user: PublicKey,
|
user: PublicKey,
|
||||||
) -> Result<Vec<RelayUrl>, Error> {
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
let events: Vec<Event> = client
|
let events: Vec<Event> = client
|
||||||
@@ -1498,24 +1536,21 @@ const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GraspServerResult {
|
pub struct GraspServerResult {
|
||||||
pub relay: RelayUrl,
|
pub relay: RelayUrl,
|
||||||
pub git_url: String,
|
|
||||||
/// `None` when the server accepted the data, the reason otherwise.
|
/// `None` when the server accepted the data, the reason otherwise.
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GraspServerResult {
|
impl GraspServerResult {
|
||||||
fn ok(relay: RelayUrl, git_url: String) -> Self {
|
fn ok(relay: RelayUrl) -> Self {
|
||||||
Self {
|
Self {
|
||||||
relay,
|
relay,
|
||||||
git_url,
|
|
||||||
reason: None,
|
reason: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failed(relay: RelayUrl, git_url: String, reason: impl Into<String>) -> Self {
|
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
relay,
|
relay,
|
||||||
git_url,
|
|
||||||
reason: Some(reason.into()),
|
reason: Some(reason.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1711,21 +1746,16 @@ async fn push_staged_to_grasps(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
servers: &[RelayUrl],
|
servers: &[RelayUrl],
|
||||||
|
executor: &BackgroundExecutor,
|
||||||
push: fn(&Path, &str, &str, &str) -> Result<(), Error>,
|
push: fn(&Path, &str, &str, &str) -> Result<(), Error>,
|
||||||
) -> PushOutcome {
|
) -> PushOutcome {
|
||||||
let mut outcome = PushOutcome::default();
|
let mut outcome = PushOutcome::default();
|
||||||
|
|
||||||
if refs.is_empty() {
|
|
||||||
return outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
for relay in servers {
|
for relay in servers {
|
||||||
let Some(base) = grasp_base_url(relay) else {
|
let Some(base) = grasp_base_url(relay) else {
|
||||||
outcome.servers.push(GraspServerResult::failed(
|
outcome
|
||||||
relay.clone(),
|
.servers
|
||||||
relay.to_string(),
|
.push(GraspServerResult::failed(relay.clone(), "no domain"));
|
||||||
"no domain",
|
|
||||||
));
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let git_url = format!("{base}/{owner}/{repo_id}.git");
|
let git_url = format!("{base}/{owner}/{repo_id}.git");
|
||||||
@@ -1739,7 +1769,7 @@ async fn push_staged_to_grasps(
|
|||||||
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
|
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
// Give the server's ingest a moment before re-staging.
|
// Give the server's ingest a moment before re-staging.
|
||||||
std::thread::sleep(GRASP_RETRY_DELAY);
|
executor.timer(GRASP_RETRY_DELAY).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (event, created_at) =
|
let (event, created_at) =
|
||||||
@@ -1806,11 +1836,9 @@ async fn push_staged_to_grasps(
|
|||||||
log::warn!("grasp push failed: {relay}: {reason}");
|
log::warn!("grasp push failed: {relay}: {reason}");
|
||||||
outcome
|
outcome
|
||||||
.servers
|
.servers
|
||||||
.push(GraspServerResult::failed(relay.clone(), git_url, reason));
|
.push(GraspServerResult::failed(relay.clone(), reason));
|
||||||
}
|
}
|
||||||
None => outcome
|
None => outcome.servers.push(GraspServerResult::ok(relay.clone())),
|
||||||
.servers
|
|
||||||
.push(GraspServerResult::ok(relay.clone(), git_url)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1968,13 +1996,9 @@ mod tests {
|
|||||||
fn push_outcome_reports_partial_failures() {
|
fn push_outcome_reports_partial_failures() {
|
||||||
let outcome = PushOutcome {
|
let outcome = PushOutcome {
|
||||||
servers: vec![
|
servers: vec![
|
||||||
GraspServerResult::ok(
|
GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")),
|
||||||
RelayUrl::parse("wss://gitnostr.com").expect("url"),
|
|
||||||
"https://gitnostr.com/npub1owner/repo.git".to_owned(),
|
|
||||||
),
|
|
||||||
GraspServerResult::failed(
|
GraspServerResult::failed(
|
||||||
RelayUrl::parse("wss://relay.ngit.dev").expect("url"),
|
RelayUrl::parse("wss://relay.ngit.dev").expect("url"),
|
||||||
"https://relay.ngit.dev/npub1owner/repo.git".to_owned(),
|
|
||||||
"remote: ERR authorisation failed: No state events in purgatory\nfatal: ...",
|
"remote: ERR authorisation failed: No state events in purgatory\nfatal: ...",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,22 +9,17 @@ use settings::{CheckoutRecord, SettingsStore};
|
|||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::repo_mirror_root;
|
||||||
|
use crate::local_repos::LocalReposStore;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
use crate::repos::{LocalReposStore, RepoListStore};
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// How often the statuses are recomputed against the local refs.
|
/// How often the statuses are recomputed against the local refs.
|
||||||
///
|
|
||||||
/// A commit lands in a checkout long before the remote reconciliation cadence,
|
|
||||||
/// so this fast pass surfaces ready-to-push and ready-to-contribute checkouts
|
|
||||||
/// within a second or two. It reads the tracking refs only, no network.
|
|
||||||
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// How often a full pass refreshes the remotes while any repository panel is open.
|
/// How often a full pass refreshes the remotes while any repository panel is open.
|
||||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
||||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
@@ -45,10 +40,6 @@ pub struct CheckoutStatus {
|
|||||||
/// Commit the branch points at, for tip-based PR dedupe.
|
/// Commit the branch points at, for tip-based PR dedupe.
|
||||||
pub head: String,
|
pub head: String,
|
||||||
/// What the branch is compared against.
|
/// What the branch is compared against.
|
||||||
/// For ready-to-contribute statuses, the announced HEAD branch.
|
|
||||||
/// The fallbacks are `main`, then the first local branch.
|
|
||||||
/// For ready-to-push statuses, the remote-tracking ref.
|
|
||||||
/// Unpushed commits are counted against it.
|
|
||||||
///
|
///
|
||||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||||
pub base: String,
|
pub base: String,
|
||||||
@@ -66,11 +57,6 @@ struct Remembered {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Global store of local-checkout associations and per-checkout statuses.
|
/// Global store of local-checkout associations and per-checkout statuses.
|
||||||
///
|
|
||||||
/// Readers (the sidebar rows, the repository panels) observe this store and
|
|
||||||
/// derive what they display from their own snapshots, so publishing needs no
|
|
||||||
/// fine-grained entities: the store notifies when a slice changed and each
|
|
||||||
/// reader re-derives only what it shows.
|
|
||||||
pub struct CheckoutsStore {
|
pub struct CheckoutsStore {
|
||||||
/// Checkout paths per announced repository.
|
/// Checkout paths per announced repository.
|
||||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
@@ -93,6 +79,8 @@ pub struct CheckoutsStore {
|
|||||||
/// A recompute defaults the base the same way.
|
/// A recompute defaults the base the same way.
|
||||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
|
/// True while the timer between a scheduled refresh and its run is pending.
|
||||||
|
debounce_pending: bool,
|
||||||
local_pending: bool,
|
local_pending: bool,
|
||||||
/// When the last full pass (with a remote refresh) completed.
|
/// When the last full pass (with a remote refresh) completed.
|
||||||
///
|
///
|
||||||
@@ -141,6 +129,7 @@ impl CheckoutsStore {
|
|||||||
this.statuses.clear();
|
this.statuses.clear();
|
||||||
this.push_statuses.clear();
|
this.push_statuses.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refresh(cx);
|
this.refresh(cx);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -163,6 +152,7 @@ impl CheckoutsStore {
|
|||||||
push_statuses: HashMap::new(),
|
push_statuses: HashMap::new(),
|
||||||
requested_head: HashMap::new(),
|
requested_head: HashMap::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
|
debounce_pending: false,
|
||||||
local_pending: false,
|
local_pending: false,
|
||||||
last_full_sync: None,
|
last_full_sync: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
@@ -278,13 +268,13 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolve the associations and the requested statuses.
|
/// Re-resolve the associations and the requested statuses.
|
||||||
///
|
|
||||||
/// Requests arriving while a pass runs fold into a follow-up.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refresh.request() != RefreshRequest::Schedule {
|
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.debounce_pending = true;
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||||
@@ -293,13 +283,8 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One full resolve and apply cycle, the debounced entry point.
|
/// One full resolve and apply cycle, the debounced entry point.
|
||||||
///
|
|
||||||
/// Re-resolves the associations from the settings, the scan and the
|
|
||||||
/// announcements, then recomputes the requested statuses against freshly
|
|
||||||
/// fetched remotes. Full passes run on every input change and on the
|
|
||||||
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
|
|
||||||
/// the fast local pass.
|
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.debounce_pending = false;
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
|
|
||||||
let records = {
|
let records = {
|
||||||
@@ -321,7 +306,7 @@ impl CheckoutsStore {
|
|||||||
|
|
||||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||||
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||||
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
let cache_root = repo_mirror_root().canonicalize().ok();
|
||||||
|
|
||||||
let requested: Vec<(RepoAddr, Option<String>)> = self
|
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||||
.status_requested
|
.status_requested
|
||||||
@@ -342,7 +327,9 @@ impl CheckoutsStore {
|
|||||||
//
|
//
|
||||||
// The facts are the origin URL and the root commit, both CLI reads.
|
// The facts are the origin URL and the root commit, both CLI reads.
|
||||||
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||||
for path in scanned.iter() {
|
for scanned in scanned.iter() {
|
||||||
|
let path = &scanned.path;
|
||||||
|
|
||||||
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
|
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
|
||||||
if cache_root
|
if cache_root
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -421,10 +408,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule the fast local status pass, unless one is already pending.
|
/// Schedule the fast local status pass, unless one is already pending.
|
||||||
///
|
|
||||||
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
|
|
||||||
/// the local refs, with no network, so a new commit in a checkout surfaces in
|
|
||||||
/// a second or two instead of at the next remote reconciliation.
|
|
||||||
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.local_pending {
|
if self.local_pending {
|
||||||
return;
|
return;
|
||||||
@@ -442,10 +425,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The fast local status pass.
|
/// The fast local status pass.
|
||||||
///
|
|
||||||
/// Recomputes the statuses against the local refs; when the remote
|
|
||||||
/// reconciliation cadence elapsed, it runs a full pass instead so pushes
|
|
||||||
/// made elsewhere do not linger as `to push`.
|
|
||||||
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
||||||
// Nothing watched: the chain idles out until a new request restarts it.
|
// Nothing watched: the chain idles out until a new request restarts it.
|
||||||
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
||||||
@@ -453,7 +432,7 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A full pass or a fresh request covers this tick, skip it.
|
// A full pass or a fresh request covers this tick, skip it.
|
||||||
if self.refresh.running() || self.refresh.debouncing() {
|
if self.refresh.running() || self.debounce_pending {
|
||||||
self.schedule_local_pass(cx);
|
self.schedule_local_pass(cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -480,10 +459,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute the requested statuses against the tracking refs only.
|
/// Recompute the requested statuses against the tracking refs only.
|
||||||
///
|
|
||||||
/// The refs were last refreshed by a full pass. Comparing against them is
|
|
||||||
/// enough to pick up new local commits, and skipping the network keeps
|
|
||||||
/// this pass cheap enough to run every [`LOCAL_POLL`].
|
|
||||||
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
let associations = self.by_repo.clone();
|
let associations = self.by_repo.clone();
|
||||||
|
|
||||||
@@ -515,7 +490,7 @@ impl CheckoutsStore {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
// A full pass or a fresh request will apply fresher data
|
// A full pass or a fresh request will apply fresher data
|
||||||
// (the tracking refs move only when a full pass fetches).
|
// (the tracking refs move only when a full pass fetches).
|
||||||
if this.refresh.running() || this.refresh.debouncing() {
|
if this.refresh.running() || this.debounce_pending {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,10 +600,6 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `ready to push` status of one checkout of the user's own repository.
|
/// The `ready to push` status of one checkout of the user's own repository.
|
||||||
///
|
|
||||||
/// `fetch` refreshes the remote heads first, so a full pass sees pushes made
|
|
||||||
/// elsewhere; the fast local pass skips it and compares against the tracking
|
|
||||||
/// refs left by the last full pass, which is enough to detect local commits.
|
|
||||||
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||||
if signed_git::worktree_dirty(path) {
|
if signed_git::worktree_dirty(path) {
|
||||||
return None;
|
return None;
|
||||||
@@ -639,8 +610,6 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|||||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||||
|
|
||||||
if fetch {
|
if fetch {
|
||||||
// Refresh the remote heads first.
|
|
||||||
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
|
||||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,10 +637,6 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the requested statuses against the checkout paths of `associations`.
|
/// Compute the requested statuses against the checkout paths of `associations`.
|
||||||
///
|
|
||||||
/// Shared by the full and the local pass. `fetch` refreshes the checkouts'
|
|
||||||
/// remote heads first, so the full pass sees remote moves; the fast local
|
|
||||||
/// pass reads the tracking refs only, which is enough to detect local commits.
|
|
||||||
fn compute_statuses(
|
fn compute_statuses(
|
||||||
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
requested: &[(RepoAddr, Option<String>)],
|
requested: &[(RepoAddr, Option<String>)],
|
||||||
@@ -727,12 +692,14 @@ pub fn pr_proposes_checkout(
|
|||||||
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let branch_matches = pr
|
let branch_matches = pr
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find(|t| t.kind() == "branch-name")
|
.find(|t| t.kind() == "branch-name")
|
||||||
.and_then(|t| t.content())
|
.and_then(|t| t.content())
|
||||||
.is_some_and(|name| name == checkout.branch);
|
.is_some_and(|name| name == checkout.branch);
|
||||||
|
|
||||||
// A renamed branch falls back to the proposed tip commit.
|
// A renamed branch falls back to the proposed tip commit.
|
||||||
let tip_matches = pr
|
let tip_matches = pr
|
||||||
.tags
|
.tags
|
||||||
@@ -740,6 +707,7 @@ pub fn pr_proposes_checkout(
|
|||||||
.find(|t| t.kind() == "c")
|
.find(|t| t.kind() == "c")
|
||||||
.and_then(|t| t.content())
|
.and_then(|t| t.content())
|
||||||
.is_some_and(|tip| tip == checkout.head);
|
.is_some_and(|tip| tip == checkout.head);
|
||||||
|
|
||||||
branch_matches || tip_matches
|
branch_matches || tip_matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use gpui::{App, Global};
|
use anyhow::Result;
|
||||||
|
use gix::Repository;
|
||||||
|
use signed_core::RepoAddr;
|
||||||
use signed_git::GitCache;
|
use signed_git::GitCache;
|
||||||
|
|
||||||
struct GlobalGitStore(GitCache);
|
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
|
||||||
|
|
||||||
impl Global for GlobalGitStore {}
|
fn git_cache() -> &'static GitCache {
|
||||||
|
GIT_CACHE
|
||||||
|
.get()
|
||||||
|
.expect("git cache is initialized by signed_state::init")
|
||||||
|
}
|
||||||
|
|
||||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
/// The root directory of the repository mirrors.
|
||||||
#[derive(Debug, Clone)]
|
pub(crate) fn repo_mirror_root() -> PathBuf {
|
||||||
pub struct GitStore(GitCache);
|
git_cache().root().to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
impl GitStore {
|
/// The on-disk path of the mirror of `addr`.
|
||||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf {
|
||||||
let store = Self::new(root);
|
git_cache().repo_path(addr)
|
||||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
}
|
||||||
store
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn global(cx: &App) -> Self {
|
/// Open the mirror of `addr`, if it has been cloned.
|
||||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
pub fn open_repo_mirror(addr: &RepoAddr) -> Result<Option<Repository>> {
|
||||||
}
|
git_cache().open(addr)
|
||||||
|
}
|
||||||
|
|
||||||
fn new(root: impl Into<PathBuf>) -> Self {
|
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
|
||||||
Self(GitCache::new(root.into()))
|
pub fn ensure_repo_mirror<U: AsRef<str>>(addr: &RepoAddr, clone_urls: &[U]) -> Result<Repository> {
|
||||||
}
|
git_cache().ensure_clone(addr, clone_urls)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cache(&self) -> &GitCache {
|
pub(crate) fn set_git_cache(root: impl Into<PathBuf>) {
|
||||||
&self.0
|
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
|
||||||
|
log::warn!("git cache root is already set, keeping the first one");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,11 +104,16 @@ impl Inbox {
|
|||||||
|
|
||||||
/// Sign the state with a random key and store it locally.
|
/// Sign the state with a random key and store it locally.
|
||||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = Backend::global(cx).read(cx).client();
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod backend;
|
|||||||
mod checkouts;
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
mod inbox;
|
mod inbox;
|
||||||
|
mod local_repos;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
@@ -11,14 +12,17 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||||
pub use git_store::GitStore;
|
use git_store::set_git_cache;
|
||||||
|
pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path};
|
||||||
use gpui::{App, AppContext};
|
use gpui::{App, AppContext};
|
||||||
pub use inbox::{Inbox, query_inbox};
|
pub use inbox::{Inbox, query_inbox};
|
||||||
|
pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos};
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use refresh::{RefreshGate, RefreshRequest};
|
pub use refresh::{RefreshGate, RefreshRequest};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
pub use repos::{RepoActivityCounts, RepoListStore};
|
||||||
|
pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind};
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -31,6 +35,7 @@ pub fn init(
|
|||||||
// rustls uses the `aws_lc_rs` provider by default.
|
// rustls uses the `aws_lc_rs` provider by default.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
|
// Initialize the nostr client and signer
|
||||||
let (client, signer) = cx.foreground_executor().block_on(async move {
|
let (client, signer) = cx.foreground_executor().block_on(async move {
|
||||||
let path = db_path.as_ref().to_path_buf();
|
let path = db_path.as_ref().to_path_buf();
|
||||||
new_backend(path)
|
new_backend(path)
|
||||||
@@ -38,11 +43,22 @@ pub fn init(
|
|||||||
.expect("failed to initialize nostr backend")
|
.expect("failed to initialize nostr backend")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set Git cache for the repos root
|
||||||
|
set_git_cache(repos_root);
|
||||||
|
|
||||||
|
// Set global stores for the backend
|
||||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
|
|
||||||
|
// Set global stores for the profile
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
|
// Set global stores for the repo list and local repos
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
GitStore::set_global(repos_root, cx);
|
|
||||||
|
// Set global stores for the local repos
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
|
|
||||||
|
// Set global stores for the checkouts
|
||||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +66,10 @@ pub fn init(
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||||
|
set_git_cache(PathBuf::new());
|
||||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::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);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
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, repo_addr};
|
||||||
|
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
||||||
|
|
||||||
|
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||||
|
|
||||||
|
impl Global for GlobalLocalReposStore {}
|
||||||
|
|
||||||
|
/// Store of the git repositories discovered under a set of scan paths.
|
||||||
|
pub struct LocalReposStore {
|
||||||
|
pub roots: Arc<Vec<PathBuf>>,
|
||||||
|
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.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
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NIP-34 coordinate a repository's detection resolved, when both the owner
|
||||||
|
/// and the identifier were recovered.
|
||||||
|
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(repo_addr(owner, identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scanned repository resolved against the known announcements.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedLocalRepo {
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// `None` for a plain repository.
|
||||||
|
pub nip34: Option<Nip34Binding>,
|
||||||
|
/// The known announcement this repository is bound to, when one matched.
|
||||||
|
pub announcement: Option<Announcement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedLocalRepo {
|
||||||
|
/// The repository's directory name, or `Untitled` when the path has none.
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the scanned repositories against the known announcements.
|
||||||
|
pub fn resolve_local_repos(
|
||||||
|
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!(resolve_local_repos(&[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 = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]);
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unmatched_repository_keeps_its_binding() {
|
||||||
|
let repo = bound(KEY, "unlisted");
|
||||||
|
|
||||||
|
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert!(resolved[0].announcement.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resolved[0].nip34.as_ref().map(|binding| binding.kind),
|
||||||
|
Some(Nip34Kind::Initialized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_repository_is_kept_without_a_binding() {
|
||||||
|
let repo = LocalRepo {
|
||||||
|
path: PathBuf::from("plain"),
|
||||||
|
nip34: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||||
|
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert!(resolved[0].nip34.is_none());
|
||||||
|
assert!(resolved[0].announcement.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_name_is_the_directory_name() {
|
||||||
|
let repo = LocalRepo {
|
||||||
|
path: PathBuf::from("/tmp/my-repo"),
|
||||||
|
nip34: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||||
|
|
||||||
|
assert_eq!(resolved[0].name(), SharedString::from("my-repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_without_a_directory_name_is_untitled() {
|
||||||
|
let repo = LocalRepo {
|
||||||
|
path: PathBuf::from("/"),
|
||||||
|
nip34: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||||
|
|
||||||
|
assert_eq!(resolved[0].name(), SharedString::from("Untitled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,20 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
||||||
WeakEntity,
|
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
|
||||||
|
/// How long to wait for more requests before firing a batched fetch.
|
||||||
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
/// Max authors per profile request, keeping each filter within relay limits.
|
||||||
|
const REQUEST_CHUNK: usize = 100;
|
||||||
|
/// Recent profiles prefetched at startup and read back from the cache.
|
||||||
|
const WARM_LIMIT: usize = 500;
|
||||||
|
|
||||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
@@ -50,7 +56,7 @@ impl Profile {
|
|||||||
return SharedString::from(name.trim().to_owned());
|
return SharedString::from(name.trim().to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key, 4))
|
SharedString::from(shorten_pubkey(self.public_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn picture(&self) -> Option<SharedString> {
|
pub fn picture(&self) -> Option<SharedString> {
|
||||||
@@ -62,9 +68,6 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long to wait for more requests before firing a batched sync.
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
|
|
||||||
/// Global profile cache.
|
/// Global profile cache.
|
||||||
///
|
///
|
||||||
/// Profiles are fetched in batches and kept as plain data.
|
/// Profiles are fetched in batches and kept as plain data.
|
||||||
@@ -92,40 +95,26 @@ impl ProfileStore {
|
|||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
BackendEvent::NostrUpdate(updates) => {
|
|
||||||
for update in updates
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
.iter()
|
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||||
.filter(|update| update.kind == Kind::Metadata)
|
this.apply_authors(authors.clone(), cx);
|
||||||
{
|
|
||||||
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.
|
cx.spawn(async move |this, cx| {
|
||||||
let client = backend.read(cx).client();
|
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
log::error!("Failed to handle requests: {e}");
|
||||||
let entity = cx.entity().downgrade();
|
}
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| {
|
|
||||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||||
log::warn!("profile store dropped before initial load could run: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -160,11 +149,9 @@ impl ProfileStore {
|
|||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Parse off the main thread.
|
|
||||||
// Only plain profiles cross back.
|
|
||||||
let profiles: Vec<Profile> = events
|
let profiles: Vec<Profile> = events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
@@ -176,7 +163,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -186,53 +173,13 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
/// Re-read the latest metadata of `authors` from the local database in one query.
|
||||||
let backend = Backend::global(cx);
|
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
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() {
|
if authors.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -243,9 +190,9 @@ impl ProfileStore {
|
|||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Pick the latest metadata per author off the main thread.
|
// Pick the latest metadata per author off the main thread.
|
||||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match latest.get(&event.pubkey) {
|
match latest.get(&event.pubkey) {
|
||||||
Some((ts, _)) if *ts >= event.created_at => {}
|
Some((ts, _)) if *ts >= event.created_at => {}
|
||||||
@@ -269,7 +216,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -279,14 +226,18 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, Error>(())
|
||||||
});
|
})
|
||||||
task.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
///
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
|
self.apply_authors(authors, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch metadata for requested authors in batches, debounced to collect requests.
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
this: WeakEntity<ProfileStore>,
|
this: WeakEntity<ProfileStore>,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -305,15 +256,20 @@ impl ProfileStore {
|
|||||||
|
|
||||||
// The channel has no async timeout, race the receive against a timer.
|
// The channel has no async timeout, race the receive against a timer.
|
||||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let recv = receiver.recv_async();
|
let recv = receiver.recv_async();
|
||||||
futures::pin_mut!(recv);
|
futures::pin_mut!(recv);
|
||||||
|
|
||||||
match futures::future::select(recv, timer).await {
|
match futures::future::select(recv, timer).await {
|
||||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
@@ -323,18 +279,21 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let filter = Filter::new()
|
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||||
.kind(Kind::Metadata)
|
|
||||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
|
||||||
|
|
||||||
// Negentropy-sync with the bootstrap relays.
|
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||||
// Synced events are written to the database directly, no NostrUpdate.
|
let opts = SyncOptions::default();
|
||||||
// Re-apply from the database afterwards.
|
let filter = Filter::new()
|
||||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
.kind(Kind::Metadata)
|
||||||
Ok(_) => {
|
.authors(chunk.iter().copied());
|
||||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
|
||||||
|
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
||||||
|
log::warn!("profile fetch failed: {e}");
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
}
|
||||||
|
|
||||||
|
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
pub struct RefreshGate {
|
pub struct RefreshGate {
|
||||||
running: bool,
|
running: bool,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
debouncing: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RefreshRequest {
|
pub enum RefreshRequest {
|
||||||
/// No run or timer covers the request, start the debounce timer.
|
/// No run covers the request, start one now.
|
||||||
Schedule,
|
Schedule,
|
||||||
/// A run or pending timer already covers the request.
|
/// A run is in flight and covers the request, fold it into a follow-up.
|
||||||
Fold,
|
Fold,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,28 +18,16 @@ impl RefreshGate {
|
|||||||
self.running
|
self.running
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
pub fn request(&mut self) -> RefreshRequest {
|
||||||
if self.running {
|
if self.running {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
RefreshRequest::Fold
|
RefreshRequest::Fold
|
||||||
} else if self.debouncing {
|
|
||||||
RefreshRequest::Fold
|
|
||||||
} else {
|
} else {
|
||||||
self.debouncing = true;
|
|
||||||
RefreshRequest::Schedule
|
RefreshRequest::Schedule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn begin(&mut self) {
|
pub fn begin(&mut self) {
|
||||||
self.debouncing = false;
|
|
||||||
self.running = true;
|
self.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,3 +42,48 @@ impl RefreshGate {
|
|||||||
self.running = false;
|
self.running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_while_running_folds_into_a_follow_up() {
|
||||||
|
let mut gate = RefreshGate::default();
|
||||||
|
gate.begin();
|
||||||
|
|
||||||
|
assert_eq!(gate.request(), RefreshRequest::Fold);
|
||||||
|
assert!(gate.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_without_a_run_schedules() {
|
||||||
|
let mut gate = RefreshGate::default();
|
||||||
|
|
||||||
|
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
||||||
|
assert!(!gate.running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_after_a_run_schedules_again() {
|
||||||
|
let mut gate = RefreshGate::default();
|
||||||
|
gate.begin();
|
||||||
|
assert_eq!(gate.request(), RefreshRequest::Fold);
|
||||||
|
assert!(gate.finish());
|
||||||
|
|
||||||
|
assert_eq!(gate.request(), RefreshRequest::Schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abort_keeps_the_pending_request() {
|
||||||
|
let mut gate = RefreshGate::default();
|
||||||
|
gate.begin();
|
||||||
|
assert_eq!(gate.request(), RefreshRequest::Fold);
|
||||||
|
|
||||||
|
gate.abort();
|
||||||
|
assert!(!gate.running());
|
||||||
|
|
||||||
|
gate.begin();
|
||||||
|
assert!(gate.finish());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+242
-202
@@ -4,21 +4,23 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use anyhow::{Error, bail};
|
use anyhow::{Error, bail};
|
||||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||||
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
use gpui::{App, AppContext, Context, SharedString, Subscription, Task};
|
||||||
use nostr::event::IntoEventBuilder;
|
use nostr::event::IntoEventBuilder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::{EventFetchingStrategy, SettingsStore};
|
||||||
use signed_core::{
|
use signed_core::{
|
||||||
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
||||||
pull_request_patches,
|
pull_request_patches,
|
||||||
};
|
};
|
||||||
|
use signed_git::Nip34Binding;
|
||||||
|
use signed_nostr::UniversalSigner;
|
||||||
|
|
||||||
use crate::backend::{
|
use crate::backend::{
|
||||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
||||||
user_grasp_list_servers,
|
user_grasp_list_servers,
|
||||||
};
|
};
|
||||||
use crate::checkouts::CheckoutsStore;
|
use crate::checkouts::CheckoutsStore;
|
||||||
use crate::git_store::GitStore;
|
use crate::git_store::ensure_repo_mirror;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
/// Maximum size of one patch event.
|
/// Maximum size of one patch event.
|
||||||
@@ -29,7 +31,6 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
|||||||
/// Per-repository store.
|
/// Per-repository store.
|
||||||
///
|
///
|
||||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||||
/// Always derived from the local database.
|
|
||||||
pub struct RepoStore {
|
pub struct RepoStore {
|
||||||
/// NIP-34 address. `None` while the repository is local-only.
|
/// NIP-34 address. `None` while the repository is local-only.
|
||||||
addr: Option<RepoAddr>,
|
addr: Option<RepoAddr>,
|
||||||
@@ -39,6 +40,8 @@ pub struct RepoStore {
|
|||||||
/// Local working copy. The scan path for a local repository, kept when it is
|
/// Local working copy. The scan path for a local repository, kept when it is
|
||||||
/// later announced so the panel keeps its worktree.
|
/// later announced so the panel keeps its worktree.
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
|
/// NIP-34 state detected on disk for a local repository, if any.
|
||||||
|
pub nip34: Option<Nip34Binding>,
|
||||||
/// The first local pass has been applied.
|
/// The first local pass has been applied.
|
||||||
///
|
///
|
||||||
/// Views distinguish "no data yet" from a genuinely empty repository with it.
|
/// Views distinguish "no data yet" from a genuinely empty repository with it.
|
||||||
@@ -56,10 +59,6 @@ pub struct RepoStore {
|
|||||||
/// Computed with [`Self::status_by_root`] on every refresh.
|
/// Computed with [`Self::status_by_root`] on every refresh.
|
||||||
open_issue_count: usize,
|
open_issue_count: usize,
|
||||||
open_pr_count: usize,
|
open_pr_count: usize,
|
||||||
/// Incremented on every applied refresh.
|
|
||||||
///
|
|
||||||
/// Views key their derived-data caches to it instead of recomputing on every render.
|
|
||||||
version: u64,
|
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
/// Non-fatal warning of the last action, if any.
|
/// Non-fatal warning of the last action, if any.
|
||||||
///
|
///
|
||||||
@@ -85,7 +84,12 @@ pub struct RepoStore {
|
|||||||
///
|
///
|
||||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||||
root_fetches: HashSet<EventId>,
|
root_fetches: HashSet<EventId>,
|
||||||
refresh: RefreshGate,
|
/// Maintainers already synced through gossip in Uncensored mode.
|
||||||
|
///
|
||||||
|
/// Avoids re-running the maintainer Auto sync on every refresh.
|
||||||
|
synced_maintainers: HashSet<PublicKey>,
|
||||||
|
/// In-flight tasks, cancelled when the store drops.
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Backend subscription of an announced repository. `None` while local-only.
|
/// Backend subscription of an announced repository. `None` while local-only.
|
||||||
_subscription: Option<Subscription>,
|
_subscription: Option<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,7 @@ impl RepoStore {
|
|||||||
addr: Some(addr),
|
addr: Some(addr),
|
||||||
announcement: hint,
|
announcement: hint,
|
||||||
path: None,
|
path: None,
|
||||||
|
nip34: None,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
head: None,
|
head: None,
|
||||||
issues: Vec::new(),
|
issues: Vec::new(),
|
||||||
@@ -125,7 +130,6 @@ impl RepoStore {
|
|||||||
status_by_root: HashMap::new(),
|
status_by_root: HashMap::new(),
|
||||||
open_issue_count: 0,
|
open_issue_count: 0,
|
||||||
open_pr_count: 0,
|
open_pr_count: 0,
|
||||||
version: 0,
|
|
||||||
last_error: None,
|
last_error: None,
|
||||||
last_warning: None,
|
last_warning: None,
|
||||||
last_push_warning: None,
|
last_push_warning: None,
|
||||||
@@ -133,17 +137,19 @@ impl RepoStore {
|
|||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
synced_maintainers: HashSet::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscription: Some(subscription),
|
_subscription: Some(subscription),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local repository discovered by the scan, not announced to NIP-34 yet.
|
/// Local repository discovered by the scan, not announced to NIP-34 yet.
|
||||||
pub fn new_local(path: PathBuf) -> Self {
|
pub fn new_local(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
addr: None,
|
addr: None,
|
||||||
announcement: None,
|
announcement: None,
|
||||||
path: Some(path),
|
path: Some(path),
|
||||||
|
nip34,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
head: None,
|
head: None,
|
||||||
issues: Vec::new(),
|
issues: Vec::new(),
|
||||||
@@ -153,7 +159,6 @@ impl RepoStore {
|
|||||||
status_by_root: HashMap::new(),
|
status_by_root: HashMap::new(),
|
||||||
open_issue_count: 0,
|
open_issue_count: 0,
|
||||||
open_pr_count: 0,
|
open_pr_count: 0,
|
||||||
version: 0,
|
|
||||||
last_error: None,
|
last_error: None,
|
||||||
last_warning: None,
|
last_warning: None,
|
||||||
last_push_warning: None,
|
last_push_warning: None,
|
||||||
@@ -161,11 +166,24 @@ impl RepoStore {
|
|||||||
cloning: false,
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
synced_maintainers: HashSet::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscription: None,
|
_subscription: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An announced repository whose working copy is already on disk.
|
||||||
|
pub fn from_worktree(
|
||||||
|
addr: RepoAddr,
|
||||||
|
announcement: Announcement,
|
||||||
|
path: PathBuf,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let mut store = Self::new(addr, Some(announcement), cx);
|
||||||
|
store.path = Some(path);
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
/// Switch a local repository to its NIP-34 mode, keeping its path.
|
/// Switch a local repository to its NIP-34 mode, keeping its path.
|
||||||
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
|
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
|
||||||
self.addr = Some(announcement.addr());
|
self.addr = Some(announcement.addr());
|
||||||
@@ -190,34 +208,22 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
BackendEvent::RepoUpdates(updates) => updates.iter().any(|update| {
|
||||||
// Deletions may target any event of this repository.
|
|
||||||
let deletion =
|
let deletion =
|
||||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
let coordinate = update.coordinate.as_ref() == Some(addr);
|
let coordinate = update.coordinate.as_ref() == Some(addr);
|
||||||
let author = update.author == addr.public_key;
|
let author = update.author == addr.public_key;
|
||||||
let kind = update.kind == Kind::GitRepoAnnouncement;
|
|
||||||
// NIP-22 comments carry no `a` tag.
|
let authored = (update.kind == Kind::GitRepoAnnouncement
|
||||||
// Coordinate matching fails for them.
|
|| update.kind == Kind::RepoState)
|
||||||
// Any comment may reference this repository's roots.
|
&& author;
|
||||||
|
|
||||||
let comment = update.kind == Kind::Comment;
|
let comment = update.kind == Kind::Comment;
|
||||||
// Status events may omit their `a` tag, NIP-34.
|
|
||||||
// Any status event may reference a root of this repository.
|
|
||||||
let status = RepoStatus::from_kind(update.kind).is_some();
|
let status = RepoStatus::from_kind(update.kind).is_some();
|
||||||
|
|
||||||
deletion || coordinate || (author && kind) || comment || status
|
deletion || coordinate || authored || comment || status
|
||||||
}),
|
}),
|
||||||
BackendEvent::Published(event) => {
|
|
||||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
|
||||||
let author = event.pubkey == addr.public_key;
|
|
||||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
|
|
||||||
// Locally published deletions may target any event of this repository.
|
|
||||||
// Refresh so they take effect immediately, like relay deletions.
|
|
||||||
let deletion =
|
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
coordinate || (kind && author) || deletion
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,8 +251,6 @@ impl RepoStore {
|
|||||||
/// Announcement, state, activity and deletions targeting it.
|
/// Announcement, state, activity and deletions targeting it.
|
||||||
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
||||||
let mut filters = vec![
|
let mut filters = vec![
|
||||||
// Announcement and state share author and identifier.
|
|
||||||
// They combine into one filter, one fewer negentropy reconciliation per relay.
|
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||||
.author(addr.public_key)
|
.author(addr.public_key)
|
||||||
@@ -276,9 +280,71 @@ impl RepoStore {
|
|||||||
self.repo_relays.extend(new.iter().cloned());
|
self.repo_relays.extend(new.iter().cloned());
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let filters = Self::repo_filters(&addr);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
|
backend.connect_repo_relays(new, filters, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
|
||||||
|
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
|
||||||
|
let mut pubkeys = maintainers.to_vec();
|
||||||
|
// NIP-34 events tag the announcement author,
|
||||||
|
// which may not be a maintainer for subordinate forks.
|
||||||
|
if !pubkeys.contains(&addr.public_key) {
|
||||||
|
pubkeys.push(addr.public_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![
|
||||||
|
// Announcement and state events, including co-maintainer states.
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||||
|
.authors(pubkeys.clone())
|
||||||
|
.identifier(addr.identifier.clone()),
|
||||||
|
// Activity tagging a maintainer, resolved to their read relays.
|
||||||
|
Filter::new()
|
||||||
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
|
.coordinate(addr)
|
||||||
|
.pubkeys(pubkeys.clone()),
|
||||||
|
// Activity authored by a maintainer, resolved to their write relays.
|
||||||
|
Filter::new()
|
||||||
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
|
.coordinate(addr)
|
||||||
|
.authors(pubkeys.clone()),
|
||||||
|
// Deletions authored by a maintainer.
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
|
.authors(pubkeys),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting
|
||||||
|
fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context<Self>) {
|
||||||
|
let strategy = SettingsStore::try_global(cx)
|
||||||
|
.map(|store| store.read(cx).settings().event_fetching)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if strategy != EventFetchingStrategy::Uncensored {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(addr) = self.addr.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !maintainers
|
||||||
|
.iter()
|
||||||
|
.any(|public_key| !self.synced_maintainers.contains(public_key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.synced_maintainers.extend(maintainers.iter().copied());
|
||||||
|
|
||||||
|
let filters = Self::maintainer_filters(&addr, maintainers);
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
|
backend.update(cx, |backend, cx| {
|
||||||
|
backend.sync_auto(filters, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,18 +361,10 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-query the local database and update all fields.
|
/// Re-query the local database and update all fields.
|
||||||
///
|
|
||||||
/// Runs immediately. The backend pump already batches the relay events that
|
|
||||||
/// trigger a refresh, so no per-store debounce is needed.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.addr.is_none() {
|
if self.addr.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.refresh.request() != RefreshRequest::Schedule {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.run_refresh(cx);
|
self.run_refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,8 +373,6 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.refresh.begin();
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
@@ -334,11 +390,10 @@ impl RepoStore {
|
|||||||
|
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
// Parse and sort off the main thread.
|
|
||||||
// Only plain data crosses back into the entity.
|
|
||||||
let all_announcements = announcements
|
let all_announcements = announcements
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|e| !deletions.is_deleted(e));
|
.filter(|e| !deletions.is_deleted(e));
|
||||||
|
|
||||||
let announcement = latest(all_announcements)
|
let announcement = latest(all_announcements)
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(Announcement::from_event);
|
.and_then(Announcement::from_event);
|
||||||
@@ -363,8 +418,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-22 comments reference their root via an `E` or `e` tag.
|
|
||||||
// Not the repository's `a` tag, so query them by the root events.
|
|
||||||
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
|
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
|
|
||||||
@@ -382,8 +435,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status events may omit their `a` tag.
|
|
||||||
// Query them by the root events they reference too.
|
|
||||||
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
|
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
|
|
||||||
@@ -401,17 +452,11 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover notes, 1624, and label events, 1985, carry no `a` tag.
|
|
||||||
// Query them per root like comments and statuses.
|
|
||||||
// The events are only stored for interop and nothing displays them.
|
|
||||||
sort_newest_first(&mut issues);
|
sort_newest_first(&mut issues);
|
||||||
sort_newest_first(&mut patches);
|
sort_newest_first(&mut patches);
|
||||||
sort_newest_first(&mut pull_requests);
|
sort_newest_first(&mut pull_requests);
|
||||||
sort_oldest_first(&mut comments);
|
sort_oldest_first(&mut comments);
|
||||||
|
|
||||||
// Resolve every root's status once here.
|
|
||||||
// Render paths do HashMap lookups instead of per-root status scans.
|
|
||||||
// Those scans are quadratic, with an allocation per pair.
|
|
||||||
let maintainers = announcement
|
let maintainers = announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(Announcement::effective_maintainers)
|
.map(Announcement::effective_maintainers)
|
||||||
@@ -446,7 +491,7 @@ impl RepoStore {
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
let (
|
let (
|
||||||
announcement,
|
announcement,
|
||||||
state,
|
state,
|
||||||
@@ -461,33 +506,20 @@ impl RepoStore {
|
|||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
this.refresh.abort();
|
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let again = this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
// Compare before moving the freshly queried data in, so a pass
|
|
||||||
// that found nothing new does not notify observers. The store
|
|
||||||
// is polled in bursts while a sync is in flight; notifying on
|
|
||||||
// every identical pass would re-render the repository panel
|
|
||||||
// several times for no visible change.
|
|
||||||
//
|
|
||||||
// The first pass is the exception: it must notify even when it
|
|
||||||
// found nothing, so views can leave their loading state and show
|
|
||||||
// the empty result.
|
|
||||||
//
|
|
||||||
// Keep the open-time hint until that first pass has confirmed what
|
|
||||||
// the database holds; afterwards the database is the truth,
|
|
||||||
// including a deletion.
|
|
||||||
let keep_hint = announcement.is_none() && !this.loaded;
|
let keep_hint = announcement.is_none() && !this.loaded;
|
||||||
|
|
||||||
let first_pass = !this.loaded;
|
let first_pass = !this.loaded;
|
||||||
|
|
||||||
let head_changed = state
|
let head_changed = state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
||||||
|
|
||||||
let changed = first_pass
|
let changed = first_pass
|
||||||
|| (!keep_hint && this.announcement != announcement)
|
|| (!keep_hint && this.announcement != announcement)
|
||||||
|| head_changed
|
|| head_changed
|
||||||
@@ -502,14 +534,23 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The announcement may list relays for this repository's activity.
|
// The announcement may list relays for this repository's activity.
|
||||||
// Connect to any we have not fetched from yet.
|
|
||||||
let relays = this
|
let relays = this
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.relays.clone())
|
.map(|a| a.relays.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
this.connect_announced_relays(&relays, cx);
|
this.connect_announced_relays(&relays, cx);
|
||||||
|
|
||||||
|
// Uncensored mode also covers the maintainers' NIP-65 relays.
|
||||||
|
let maintainers = this
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(Announcement::effective_maintainers)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
this.sync_maintainer_relays(&maintainers, cx);
|
||||||
|
|
||||||
if let Some((_, head)) = state {
|
if let Some((_, head)) = state {
|
||||||
this.head = head;
|
this.head = head;
|
||||||
}
|
}
|
||||||
@@ -522,12 +563,7 @@ impl RepoStore {
|
|||||||
this.open_issue_count = open_issue_count;
|
this.open_issue_count = open_issue_count;
|
||||||
this.open_pr_count = open_pr_count;
|
this.open_pr_count = open_pr_count;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
this.version = this.version.wrapping_add(1);
|
|
||||||
|
|
||||||
// Comments and statuses without an `a` tag.
|
|
||||||
// None are addressed to the repository.
|
|
||||||
// Fetch them by the root events they reference.
|
|
||||||
// Use the bootstrap relays and the relays this repository announced.
|
|
||||||
let roots = this
|
let roots = this
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
@@ -544,13 +580,13 @@ impl RepoStore {
|
|||||||
|
|
||||||
if !new_roots.is_empty() {
|
if !new_roots.is_empty() {
|
||||||
this.root_fetches.extend(new_roots.iter().copied());
|
this.root_fetches.extend(new_roots.iter().copied());
|
||||||
// Batch the per-root filters.
|
|
||||||
// One filter per root costs a negentropy reconciliation per relay.
|
|
||||||
let mut root_filters = filters::comments_for(new_roots.clone());
|
let mut root_filters = filters::comments_for(new_roots.clone());
|
||||||
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
||||||
|
|
||||||
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.subscribe_bootstrap(root_filters.clone(), cx);
|
backend.subscribe_bootstrap(root_filters.clone(), cx);
|
||||||
backend.connect_repo_relays(announced, root_filters, cx);
|
backend.connect_repo_relays(announced, root_filters, cx);
|
||||||
@@ -560,19 +596,12 @@ impl RepoStore {
|
|||||||
if changed {
|
if changed {
|
||||||
cx.notify();
|
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))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
||||||
@@ -580,30 +609,17 @@ impl RepoStore {
|
|||||||
status_of(&self.status_by_root, root)
|
status_of(&self.status_by_root, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn version(&self) -> u64 {
|
|
||||||
self.version
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of open issues.
|
/// Number of open issues.
|
||||||
///
|
|
||||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
|
||||||
/// Issues without status events default to open.
|
|
||||||
pub fn issue_count(&self) -> usize {
|
pub fn issue_count(&self) -> usize {
|
||||||
self.open_issue_count
|
self.open_issue_count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of open pull requests.
|
/// Number of open pull requests.
|
||||||
///
|
|
||||||
/// Only root PR events count, PR updates do not.
|
|
||||||
/// They must resolve to [`RepoStatus::Open`].
|
|
||||||
pub fn pull_request_count(&self) -> usize {
|
pub fn pull_request_count(&self) -> usize {
|
||||||
self.open_pr_count
|
self.open_pr_count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `user` is the author or owner of this repository.
|
/// Whether `user` is the author or owner of this repository.
|
||||||
///
|
|
||||||
/// The author is the public key of the repository address.
|
|
||||||
/// Only the author may manage pull requests, close, reopen or merge.
|
|
||||||
pub fn is_author(&self, user: &PublicKey) -> bool {
|
pub fn is_author(&self, user: &PublicKey) -> bool {
|
||||||
self.addr
|
self.addr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -718,9 +734,12 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let signer = backend.read(cx).signer();
|
let (signer, client, user) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.signer(), backend.client(), backend.current_user())
|
||||||
|
};
|
||||||
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let Some(user) = user else {
|
||||||
self.last_error = Some("Sign in to open a pull request".into());
|
self.last_error = Some("Sign in to open a pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -755,12 +774,12 @@ impl RepoStore {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
// The PR references the root patch event.
|
// The PR references the root patch,
|
||||||
// Viewers can then find the patch without carrying it inline.
|
// viewers can then find the patch without carrying it inline.
|
||||||
let root_patch = match publish_patch_series(
|
let root_patch = match publish_patch_series(
|
||||||
&this,
|
&client,
|
||||||
cx,
|
&signer,
|
||||||
&addr,
|
&addr,
|
||||||
owner,
|
owner,
|
||||||
euc.as_deref(),
|
euc.as_deref(),
|
||||||
@@ -785,11 +804,14 @@ impl RepoStore {
|
|||||||
// Resolve the servers from the author's latest kind-10317 grasp list.
|
// Resolve the servers from the author's latest kind-10317 grasp list.
|
||||||
// The settings defaults stand in when no list is published or the query fails.
|
// The settings defaults stand in when no list is published or the query fails.
|
||||||
let author_servers = {
|
let author_servers = {
|
||||||
let query = this.update(cx, |_this, cx| {
|
let query_client = client.clone();
|
||||||
let client = Backend::global(cx).read(cx).client();
|
let published = cx
|
||||||
user_grasp_list_servers(client, user)
|
.background_spawn(
|
||||||
})?;
|
async move { user_grasp_list_servers(&query_client, user).await },
|
||||||
match cx.background_spawn(query).await {
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match published {
|
||||||
Ok(published) if !published.is_empty() => published,
|
Ok(published) if !published.is_empty() => published,
|
||||||
_ => defaults,
|
_ => defaults,
|
||||||
}
|
}
|
||||||
@@ -823,24 +845,17 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let builder = this.update(cx, |this, _cx| {
|
let builder = this.update(cx, |this, _cx| {
|
||||||
// NIP-34 PRs carry at least one clone URL.
|
|
||||||
// The tip commit is downloadable from it.
|
|
||||||
// The author's `/prs/` URLs come first.
|
|
||||||
// They are author-controlled and most likely alive.
|
|
||||||
// The announced mirrors follow.
|
|
||||||
// The list is fixed before signing.
|
|
||||||
// The pushed ref name embeds the event id.
|
|
||||||
// Every candidate URL is listed up front.
|
|
||||||
// Dead URLs are inert, the linked patch stays the source of truth.
|
|
||||||
let prs_urls: Vec<Url> = author_targets
|
let prs_urls: Vec<Url> = author_targets
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(url, _)| Url::parse(url).ok())
|
.filter_map(|(url, _)| Url::parse(url).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let base_clone = this
|
let base_clone = this
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let clone = pr_clone_urls(prs_urls, base_clone);
|
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||||
|
|
||||||
let builder = GitPullRequest {
|
let builder = GitPullRequest {
|
||||||
@@ -865,8 +880,6 @@ impl RepoStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Sign before publishing.
|
// Sign before publishing.
|
||||||
// The tip is pushed to the grasp servers under `refs/nostr/<event-id>`.
|
|
||||||
// Nak's convention, readers fetch that ref for the commit behind the `c` tag.
|
|
||||||
let event = cx
|
let event = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let signer = signer.clone();
|
let signer = signer.clone();
|
||||||
@@ -877,20 +890,22 @@ impl RepoStore {
|
|||||||
if let Some(path) = push_from.as_ref() {
|
if let Some(path) = push_from.as_ref() {
|
||||||
let tip = current_commit.to_string();
|
let tip = current_commit.to_string();
|
||||||
let reference = format!("refs/nostr/{}", event.id.to_hex());
|
let reference = format!("refs/nostr/{}", event.id.to_hex());
|
||||||
|
|
||||||
let (pushed, failures) = cx
|
let (pushed, failures) = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let path = path.clone();
|
let path = path.clone();
|
||||||
let tip = tip.clone();
|
let tip = tip.clone();
|
||||||
let reference = reference.clone();
|
let reference = reference.clone();
|
||||||
// Author servers first, then the announced base grasp servers.
|
// Author servers first, then the announced base grasp servers.
|
||||||
// The extra targets are best-effort redundancy.
|
|
||||||
let targets: Vec<(String, String)> = author_targets
|
let targets: Vec<(String, String)> = author_targets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(base_targets)
|
.chain(base_targets)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
let mut pushed = 0;
|
let mut pushed = 0;
|
||||||
|
|
||||||
for (url, label) in &targets {
|
for (url, label) in &targets {
|
||||||
match signed_git::push_commit_ref(
|
match signed_git::push_commit_ref(
|
||||||
&path, url, &tip, &reference,
|
&path, url, &tip, &reference,
|
||||||
@@ -899,6 +914,7 @@ impl RepoStore {
|
|||||||
Err(e) => failures.push(format!("{label}: {e}")),
|
Err(e) => failures.push(format!("{label}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(pushed, failures)
|
(pushed, failures)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -915,8 +931,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?;
|
|
||||||
|
|
||||||
let publish_result: Result<Event, Error> = async {
|
let publish_result: Result<Event, Error> = async {
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
require_relay_accepted(output, event)
|
require_relay_accepted(output, event)
|
||||||
@@ -933,11 +947,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx)
|
|
||||||
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// A draft PR carries a kind-1633 status event, NIP-34.
|
// A draft PR carries a kind-1633 status event, NIP-34.
|
||||||
// Publish it right after the PR event so viewers never show it open.
|
// Publish it right after the PR event so viewers never show it open.
|
||||||
if draft {
|
if draft {
|
||||||
@@ -947,8 +956,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
||||||
@@ -1012,8 +1021,12 @@ impl RepoStore {
|
|||||||
self.last_warning = None;
|
self.last_warning = None;
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let (user, client, signer) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client(), backend.signer())
|
||||||
|
};
|
||||||
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let Some(user) = user else {
|
||||||
self.last_error = Some("Sign in to update the pull request".into());
|
self.last_error = Some("Sign in to update the pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -1067,19 +1080,21 @@ impl RepoStore {
|
|||||||
self.not_announced(cx);
|
self.not_announced(cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let owner = addr.public_key;
|
let owner = addr.public_key;
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
|
||||||
let clone: Vec<Url> = self
|
let clone: Vec<Url> = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = publish_patch_series(
|
if let Err(e) = publish_patch_series(
|
||||||
&this,
|
&client,
|
||||||
cx,
|
&signer,
|
||||||
&addr,
|
&addr,
|
||||||
owner,
|
owner,
|
||||||
euc.as_deref(),
|
euc.as_deref(),
|
||||||
@@ -1095,7 +1110,7 @@ impl RepoStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = this.update(cx, |_this, _cx| {
|
let builder = {
|
||||||
let builder = GitPullRequestUpdate {
|
let builder = GitPullRequestUpdate {
|
||||||
repository: addr.clone(),
|
repository: addr.clone(),
|
||||||
pull_request_event: root.id,
|
pull_request_event: root.id,
|
||||||
@@ -1112,13 +1127,7 @@ impl RepoStore {
|
|||||||
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
||||||
None => builder,
|
None => builder,
|
||||||
}
|
}
|
||||||
})?;
|
};
|
||||||
|
|
||||||
let (client, signer) = this.update(cx, |_this, cx| {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.client(), backend.signer())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let publish_result: Result<Event, Error> = async {
|
let publish_result: Result<Event, Error> = async {
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
@@ -1127,25 +1136,16 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match publish_result {
|
if let Err(e) = publish_result {
|
||||||
Ok(event) => {
|
return this.update(cx, |this, cx| {
|
||||||
this.update(cx, |_this, cx| {
|
this.last_error = Some(e.to_string());
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
cx.notify();
|
||||||
backend.announce_published(event.clone(), cx)
|
});
|
||||||
})
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return this.update(cx, |this, cx| {
|
|
||||||
this.last_error = Some(e.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the status of a root event.
|
/// Set the status of a root event.
|
||||||
@@ -1210,8 +1210,6 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
|
||||||
|
|
||||||
let clone_urls: Vec<Url> = self
|
let clone_urls: Vec<Url> = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1237,7 +1235,7 @@ impl RepoStore {
|
|||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
|
||||||
let apply = cx.background_spawn(async move {
|
let apply = cx.background_spawn(async move {
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
|
||||||
let workdir = repo
|
let workdir = repo
|
||||||
.workdir()
|
.workdir()
|
||||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||||
@@ -1273,7 +1271,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest announcement of this repository,
|
/// The latest announcement of this repository,
|
||||||
@@ -1580,24 +1578,16 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match publish_result {
|
if let Err(e) = publish_result {
|
||||||
Ok(event) => {
|
this.update(cx, |this, cx| {
|
||||||
this.update(cx, |_this, cx| {
|
this.last_error = Some(e.to_string());
|
||||||
Backend::global(cx)
|
cx.notify();
|
||||||
.update(cx, |backend, cx| backend.announce_published(event, cx))
|
})?;
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.last_error = Some(e.to_string());
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1667,8 +1657,8 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
|||||||
/// Returns the root event, the one a PR references.
|
/// Returns the root event, the one a PR references.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn publish_patch_series(
|
async fn publish_patch_series(
|
||||||
this: &WeakEntity<RepoStore>,
|
client: &Client,
|
||||||
cx: &mut AsyncApp,
|
signer: &UniversalSigner,
|
||||||
addr: &RepoAddr,
|
addr: &RepoAddr,
|
||||||
owner: PublicKey,
|
owner: PublicKey,
|
||||||
euc: Option<&str>,
|
euc: Option<&str>,
|
||||||
@@ -1676,12 +1666,6 @@ async fn publish_patch_series(
|
|||||||
first_marker: &str,
|
first_marker: &str,
|
||||||
reply_to: Option<EventId>,
|
reply_to: Option<EventId>,
|
||||||
) -> Result<Event, Error> {
|
) -> Result<Event, Error> {
|
||||||
let (client, signer) = this.update(cx, |_this, cx| {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.client(), backend.signer())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut root: Option<Event> = None;
|
let mut root: Option<Event> = None;
|
||||||
let mut previous = reply_to;
|
let mut previous = reply_to;
|
||||||
|
|
||||||
@@ -1694,6 +1678,7 @@ async fn publish_patch_series(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
||||||
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
@@ -1708,28 +1693,25 @@ async fn publish_patch_series(
|
|||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(euc) = euc
|
if let Some(euc) = euc
|
||||||
&& let Ok(tag) = Tag::parse(["r", euc])
|
&& let Ok(tag) = Tag::parse(["r", euc])
|
||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tag) = Tag::parse(["commit", commit]) {
|
if let Ok(tag) = Tag::parse(["commit", commit]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tag) = Tag::parse(["r", commit]) {
|
if let Ok(tag) = Tag::parse(["r", commit]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
||||||
|
let event = builder.finalize_async(signer).await?;
|
||||||
let event = builder.finalize_async(&signer).await?;
|
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
let event = require_relay_accepted(output, event)?;
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
|
||||||
backend.announce_published(event.clone(), cx)
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if root.is_none() {
|
if root.is_none() {
|
||||||
root = Some(event.clone());
|
root = Some(event.clone());
|
||||||
@@ -1768,9 +1750,11 @@ fn comment_builder(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use super::{comment_builder, patch_current_commit};
|
use super::{RepoStore, comment_builder, patch_current_commit};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_format_patch_header() {
|
fn parses_format_patch_header() {
|
||||||
@@ -1816,4 +1800,60 @@ mod tests {
|
|||||||
// Signed's own `references_root` must keep matching the comment.
|
// Signed's own `references_root` must keep matching the comment.
|
||||||
assert!(signed_core::references_root(&event, &root.id));
|
assert!(signed_core::references_root(&event, &root.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maintainer_filters_name_owner_and_maintainers() {
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let maintainer = Keys::generate().public_key();
|
||||||
|
let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo");
|
||||||
|
|
||||||
|
// The owner is not among the maintainers, as on a subordinate fork.
|
||||||
|
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
|
||||||
|
assert_eq!(filters.len(), 4);
|
||||||
|
|
||||||
|
let expected = HashSet::from([owner, maintainer]);
|
||||||
|
|
||||||
|
// Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag.
|
||||||
|
let named = |filter: &Filter| -> HashSet<PublicKey> {
|
||||||
|
let authors = filter.authors.iter().flatten().copied();
|
||||||
|
let p_tag = filter
|
||||||
|
.generic_tags
|
||||||
|
.get(&SingleLetterTag::LOWERCASE_P)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|value| PublicKey::from_hex(value).ok());
|
||||||
|
authors.chain(p_tag).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Announcement and state events, scoped to the repository identifier.
|
||||||
|
let announcement = &filters[0];
|
||||||
|
assert_eq!(named(announcement), expected);
|
||||||
|
assert!(
|
||||||
|
announcement
|
||||||
|
.generic_tags
|
||||||
|
.contains_key(&SingleLetterTag::LOWERCASE_D)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Activity filters, scoped to the repository coordinate.
|
||||||
|
for filter in &filters[1..3] {
|
||||||
|
assert_eq!(named(filter), expected);
|
||||||
|
assert!(
|
||||||
|
filter
|
||||||
|
.generic_tags
|
||||||
|
.contains_key(&SingleLetterTag::LOWERCASE_A)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletions, named by author only.
|
||||||
|
let deletions = &filters[3];
|
||||||
|
assert_eq!(
|
||||||
|
deletions
|
||||||
|
.authors
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,15 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
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 nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||||
use signed_git::find_git_repos;
|
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
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 {
|
|
||||||
pub roots: Arc<Vec<PathBuf>>,
|
|
||||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
|
||||||
pub repos: Arc<Vec<PathBuf>>,
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How far back activity events count toward a repository's last activity.
|
/// How far back activity events count toward a repository's last activity.
|
||||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||||
|
|
||||||
@@ -148,6 +47,8 @@ pub struct RepoListStore {
|
|||||||
///
|
///
|
||||||
/// Used for the Popular ranking of the explore list.
|
/// Used for the Popular ranking of the explore list.
|
||||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||||
|
/// Own repositories whose state events were fetched from their announced relays.
|
||||||
|
state_synced_repos: HashSet<RepoAddr>,
|
||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
@@ -167,33 +68,11 @@ impl RepoListStore {
|
|||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
BackendEvent::SignerChanged => {
|
||||||
// Deletions may target anything we list, always refresh.
|
this.state_synced_repos.clear();
|
||||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
// Only a completed sync refreshes the list.
|
BackendEvent::RepoUpdates(_) => true,
|
||||||
// Progress ticks would re-scan the whole database several times
|
|
||||||
// per sync to reveal entries incrementally.
|
|
||||||
BackendEvent::Synced => true,
|
BackendEvent::Synced => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -215,6 +94,7 @@ impl RepoListStore {
|
|||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
last_activity: Arc::new(HashMap::new()),
|
last_activity: Arc::new(HashMap::new()),
|
||||||
counts: Arc::new(HashMap::new()),
|
counts: Arc::new(HashMap::new()),
|
||||||
|
state_synced_repos: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
@@ -233,12 +113,42 @@ impl RepoListStore {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
backend.sync_bootstraps(
|
||||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
vec![
|
||||||
backend.sync_bootstrap(filters::deletions(), cx);
|
filters::all_announcements(),
|
||||||
|
filters::all_states(),
|
||||||
|
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||||
|
filters::deletions(),
|
||||||
|
],
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the state events of the user's own repositories.
|
||||||
|
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![filters::state(&addr)], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-query the local database.
|
/// Re-query the local database.
|
||||||
///
|
///
|
||||||
/// Runs immediately. The backend pump already batches the relay events that
|
/// Runs immediately. The backend pump already batches the relay events that
|
||||||
@@ -290,9 +200,6 @@ impl RepoListStore {
|
|||||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
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
|
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| (a.addr(), a.created_at))
|
.map(|a| (a.addr(), a.created_at))
|
||||||
@@ -318,6 +225,7 @@ impl RepoListStore {
|
|||||||
let activity_filter = Filter::new()
|
let activity_filter = Filter::new()
|
||||||
.kinds(filters::ACTIVITY_KINDS)
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||||
|
|
||||||
for event in client.database().query(activity_filter).await? {
|
for event in client.database().query(activity_filter).await? {
|
||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
@@ -326,8 +234,6 @@ impl RepoListStore {
|
|||||||
if addr.kind != Kind::GitRepoAnnouncement {
|
if addr.kind != Kind::GitRepoAnnouncement {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Skip events for repos we do not list.
|
|
||||||
// The map cannot grow beyond the number of announcements.
|
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -340,13 +246,12 @@ impl RepoListStore {
|
|||||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||||
let count_filter =
|
let count_filter =
|
||||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||||
|
|
||||||
for event in client.database().query(count_filter).await? {
|
for event in client.database().query(count_filter).await? {
|
||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
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)
|
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -379,6 +284,7 @@ impl RepoListStore {
|
|||||||
this.announcements = Arc::new(announcements);
|
this.announcements = Arc::new(announcements);
|
||||||
this.last_activity = Arc::new(last_activity);
|
this.last_activity = Arc::new(last_activity);
|
||||||
this.counts = Arc::new(counts);
|
this.counts = Arc::new(counts);
|
||||||
|
this.sync_own_repo_states(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refresh.finish()
|
this.refresh.finish()
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||||
use gpui_component::avatar::Avatar;
|
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
|
||||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||||
|
|
||||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
use crate::pixel_avatar::{PixelAvatar, side_length};
|
||||||
/// It shows the user's picture or falls back to name initials.
|
|
||||||
|
/// A user avatar built on the unstyled [`gpui_base::Avatar`].
|
||||||
|
///
|
||||||
|
/// It shows the user's picture when one is set, and falls back to the
|
||||||
|
/// deterministic pixel avatar seeded from the name otherwise.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct UserAvatar {
|
pub struct Avatar {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
picture: Option<SharedString>,
|
picture: Option<SharedString>,
|
||||||
size: Size,
|
size: Size,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserAvatar {
|
impl Avatar {
|
||||||
/// Create an avatar for `name`.
|
/// Create an avatar for `name`.
|
||||||
/// The name seeds the initials fallback shown when no picture is set.
|
///
|
||||||
|
/// The name seeds the pixel fallback shown when no picture is set.
|
||||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
@@ -32,26 +37,35 @@ impl UserAvatar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sizable for UserAvatar {
|
impl Sizable for Avatar {
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||||
self.size = size.into();
|
self.size = size.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Styled for UserAvatar {
|
impl Styled for Avatar {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
&mut self.style
|
&mut self.style
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for UserAvatar {
|
impl RenderOnce for Avatar {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
Avatar::new()
|
let fallback = AvatarFallback::new()
|
||||||
.name(self.name)
|
.size_full()
|
||||||
.when_some(self.picture, |this, url| this.src(url))
|
.child(PixelAvatar::new(self.name.clone()).size_full());
|
||||||
|
|
||||||
|
BaseAvatar::new()
|
||||||
|
.size(side_length(self.size))
|
||||||
|
.flex_shrink_0()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(cx.theme().secondary)
|
||||||
|
.when_some(self.picture, |this, url| {
|
||||||
|
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
|
||||||
|
})
|
||||||
|
.fallback(fallback)
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
.with_size(self.size)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,22 +8,18 @@ use gpui_component::menu::PopupMenu;
|
|||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||||
|
|
||||||
/// A split dropdown button built on `gpui_base::Popover`.
|
/// A split dropdown button built on `gpui_base::Popover`.
|
||||||
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
/// An action element next to a caret 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.
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct DropdownButton {
|
pub struct DropdownButton {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
anchor: Anchor,
|
anchor: Anchor,
|
||||||
action: Option<AnyElement>,
|
action: Option<AnyElement>,
|
||||||
caret: Option<CaretBuilder>,
|
|
||||||
menu: Option<MenuBuilder>,
|
menu: Option<MenuBuilder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type MenuBuilder =
|
type MenuBuilder =
|
||||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
|
||||||
|
|
||||||
impl DropdownButton {
|
impl DropdownButton {
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
@@ -32,7 +28,6 @@ impl DropdownButton {
|
|||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
anchor: Anchor::TopRight,
|
anchor: Anchor::TopRight,
|
||||||
action: None,
|
action: None,
|
||||||
caret: None,
|
|
||||||
menu: None,
|
menu: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,14 +49,6 @@ impl DropdownButton {
|
|||||||
self.menu = Some(Box::new(builder));
|
self.menu = Some(Box::new(builder));
|
||||||
self
|
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 {
|
impl Styled for DropdownButton {
|
||||||
@@ -91,28 +78,24 @@ impl RenderOnce for DropdownButton {
|
|||||||
let menu_state =
|
let menu_state =
|
||||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
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()
|
h_flex()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.when_some(self.action, |this, action| this.child(action))
|
.when_some(self.action, |this, action| this.child(action))
|
||||||
.when_some(self.menu, |this, builder| {
|
.when_some(self.menu, |this, builder| {
|
||||||
|
let caret_id = popover_id.clone();
|
||||||
this.child(
|
this.child(
|
||||||
Popover::new(popover_id)
|
Popover::new(popover_id)
|
||||||
.anchor(anchor)
|
.anchor(anchor)
|
||||||
// The menu dismisses itself on outside click or Escape.
|
// The menu dismisses itself on outside click or Escape.
|
||||||
// The subscription below closes the popover along with it.
|
// The subscription below closes the popover along with it.
|
||||||
.overlay_closable(false)
|
.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(
|
.content(
|
||||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||||
Some(menu) => menu,
|
Some(menu) => menu,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod avatar;
|
||||||
mod dropdown_button;
|
mod dropdown_button;
|
||||||
mod nav_item;
|
mod nav_item;
|
||||||
mod pixel_avatar;
|
mod pixel_avatar;
|
||||||
@@ -8,11 +9,11 @@ mod setting;
|
|||||||
mod status_badge;
|
mod status_badge;
|
||||||
mod title_bar;
|
mod title_bar;
|
||||||
mod tree_row;
|
mod tree_row;
|
||||||
mod user_avatar;
|
|
||||||
|
|
||||||
pub mod copy_row;
|
pub mod copy_row;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
|
pub use avatar::Avatar;
|
||||||
pub use copy_row::{copy_row, menu_copy_row};
|
pub use copy_row::{copy_row, menu_copy_row};
|
||||||
pub use dropdown_button::DropdownButton;
|
pub use dropdown_button::DropdownButton;
|
||||||
pub use nav_item::NavItem;
|
pub use nav_item::NavItem;
|
||||||
@@ -24,5 +25,4 @@ pub use setting::{SelectOption, setting_block, setting_row};
|
|||||||
pub use status_badge::status_badge;
|
pub use status_badge::status_badge;
|
||||||
pub use title_bar::title_bar_drag_handlers;
|
pub use title_bar::title_bar_drag_handlers;
|
||||||
pub use tree_row::tree_row;
|
pub use tree_row::tree_row;
|
||||||
pub use user_avatar::UserAvatar;
|
|
||||||
pub use util::middle_truncate;
|
pub use util::middle_truncate;
|
||||||
|
|||||||
@@ -1,43 +1,46 @@
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
|
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
|
||||||
use gpui_base::StyledExt;
|
use gpui_base::StyledExt;
|
||||||
use gpui_component::{ActiveTheme, Colorize};
|
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||||
|
|
||||||
/// Number of rows and columns in the pixel grid.
|
/// Number of rows and columns in the pixel grid.
|
||||||
const GRID_SIZE: usize = 8;
|
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, so the art
|
||||||
|
/// gathers in the center instead of filling the whole avatar.
|
||||||
|
const MARGIN: usize = 1;
|
||||||
|
/// Probability that a cell in the left half of the pattern area is filled.
|
||||||
const FILL_PROBABILITY: f32 = 0.42;
|
const FILL_PROBABILITY: f32 = 0.42;
|
||||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||||
/// Minimum number of filled left-half cells.
|
/// 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;
|
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.
|
/// 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.
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct PixelAvatar {
|
pub struct PixelAvatar {
|
||||||
seed: u64,
|
seed: u64,
|
||||||
|
size: Size,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PixelAvatar {
|
impl PixelAvatar {
|
||||||
/// Create an avatar seeded from `seed`.
|
/// 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 {
|
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||||
|
size: Size::Small,
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Sizable for PixelAvatar {
|
||||||
|
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||||
|
self.size = size.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Styled for PixelAvatar {
|
impl Styled for PixelAvatar {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
&mut self.style
|
&mut self.style
|
||||||
@@ -48,16 +51,17 @@ impl RenderOnce for PixelAvatar {
|
|||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let pattern = pattern(self.seed);
|
let pattern = pattern(self.seed);
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
|
||||||
let hue = self.seed as f32 / u64::MAX as f32;
|
let hue = self.seed as f32 / u64::MAX as f32;
|
||||||
let main = theme.blue.hue(hue);
|
let main = theme.blue.hue(hue);
|
||||||
|
|
||||||
let shade = if theme.is_dark() {
|
let shade = if theme.is_dark() {
|
||||||
main.lightness((main.l * 1.6).min(0.95))
|
main.lightness((main.l * 1.6).min(0.95))
|
||||||
} else {
|
} else {
|
||||||
main.lightness((main.l * 0.45).max(0.18))
|
main.lightness((main.l * 0.45).max(0.18))
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut cells = Vec::new();
|
|
||||||
for row in 0..GRID_SIZE {
|
for row in 0..GRID_SIZE {
|
||||||
for col in 0..GRID_SIZE {
|
for col in 0..GRID_SIZE {
|
||||||
let value = pattern[row * GRID_SIZE + col];
|
let value = pattern[row * GRID_SIZE + col];
|
||||||
@@ -76,28 +80,42 @@ impl RenderOnce for PixelAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.refine_style(&self.style)
|
|
||||||
.grid()
|
.grid()
|
||||||
.grid_cols(GRID_SIZE as u16)
|
.grid_cols(GRID_SIZE as u16)
|
||||||
.grid_rows(GRID_SIZE as u16)
|
.grid_rows(GRID_SIZE as u16)
|
||||||
.size(AVATAR_SIZE)
|
.size(side_length(self.size))
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.rounded(theme.radius)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(main.opacity(0.16))
|
.bg(main.opacity(0.16))
|
||||||
.children(cells)
|
.children(cells)
|
||||||
|
.refine_style(&self.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rendered side length of an avatar at `size`, shared with [`Avatar`].
|
||||||
|
pub(crate) 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] {
|
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||||
let mut rng = PixelRng::new(seed);
|
let mut rng = PixelRng::new(seed);
|
||||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||||
let mut filled = 0usize;
|
let mut filled = 0usize;
|
||||||
|
|
||||||
for row in 0..GRID_SIZE {
|
// Only the inner rows and the inner left half are candidates; mirroring
|
||||||
for col in 0..GRID_SIZE / 2 {
|
// 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) {
|
if rng.chance(FILL_PROBABILITY) {
|
||||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||||
@@ -106,18 +124,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 {
|
if filled < MIN_FILLED {
|
||||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
let total = art_rows * art_columns;
|
||||||
let start = (rng.next() % half as u64) as usize;
|
let start = (rng.next() % total as u64) as usize;
|
||||||
for offset in 0..half {
|
|
||||||
|
for offset in 0..total {
|
||||||
if filled >= MIN_FILLED {
|
if filled >= MIN_FILLED {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let ix = (start + offset) % half;
|
|
||||||
let row = ix / (GRID_SIZE / 2);
|
let ix = (start + offset) % total;
|
||||||
let col = ix % (GRID_SIZE / 2);
|
let row = MARGIN + ix / art_columns;
|
||||||
|
let col = MARGIN + ix % art_columns;
|
||||||
|
|
||||||
if pattern[row * GRID_SIZE + col] == 0 {
|
if pattern[row * GRID_SIZE + col] == 0 {
|
||||||
set_cell(&mut pattern, row, col, 1);
|
set_cell(&mut pattern, row, col, 1);
|
||||||
filled += 1;
|
filled += 1;
|
||||||
|
|||||||
@@ -1,7 +1,49 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
|
||||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
pub fn shorten_pubkey(public_key: PublicKey) -> String {
|
||||||
let npub = public_key.to_bech32().unwrap();
|
let encoded = public_key
|
||||||
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
|
.to_bech32()
|
||||||
|
.unwrap_or_else(|_| public_key.to_hex());
|
||||||
|
|
||||||
|
truncate_middle(&encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_middle(value: &str) -> String {
|
||||||
|
const HEAD_CHARS: usize = 9;
|
||||||
|
const TAIL_CHARS: usize = 4;
|
||||||
|
|
||||||
|
let length = value.chars().count();
|
||||||
|
if length <= HEAD_CHARS + TAIL_CHARS + 3 {
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
let head: String = value.chars().take(HEAD_CHARS).collect();
|
||||||
|
let tail: String = value.chars().skip(length - TAIL_CHARS).collect();
|
||||||
|
|
||||||
|
format!("{head}...{tail}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PUBLIC_KEY_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortens_a_valid_pubkey() {
|
||||||
|
let public_key = PublicKey::from_hex(PUBLIC_KEY_HEX).expect("valid pubkey");
|
||||||
|
let npub = public_key.to_bech32().expect("valid pubkey encodes");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shorten_pubkey(public_key),
|
||||||
|
format!("{}...{}", &npub[..9], &npub[npub.len() - 4..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_short_values_intact() {
|
||||||
|
assert_eq!(truncate_middle("npub1short"), "npub1short");
|
||||||
|
assert_eq!(truncate_middle("thirteenchars"), "thirteenchars");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ use gpui_component::{
|
|||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
||||||
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{placeholder, tree_row};
|
use signed_ui::{placeholder, tree_row};
|
||||||
use utils::relative_time_secs;
|
use utils::relative_time_secs;
|
||||||
|
|
||||||
use crate::views::tree::{build_tree_items, tree_items};
|
use crate::views::tree::{build_tree_items, tree_items};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const TREE_WIDTH: f32 = 260.;
|
const TREE_WIDTH: f32 = 260.;
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@ impl Render for DiffPane {
|
|||||||
|
|
||||||
pub struct CommitDiffView {
|
pub struct CommitDiffView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
store: Entity<RepoStore>,
|
||||||
worktree: PathBuf,
|
worktree: PathBuf,
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
commit: FileCommit,
|
commit: FileCommit,
|
||||||
@@ -300,10 +303,12 @@ pub struct CommitDiffView {
|
|||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommitDiffView {
|
impl CommitDiffView {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
store: Entity<RepoStore>,
|
||||||
worktree: PathBuf,
|
worktree: PathBuf,
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
commit_id: String,
|
commit_id: String,
|
||||||
@@ -319,6 +324,7 @@ impl CommitDiffView {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
store,
|
||||||
worktree,
|
worktree,
|
||||||
repo_name,
|
repo_name,
|
||||||
commit: FileCommit {
|
commit: FileCommit {
|
||||||
@@ -331,6 +337,7 @@ impl CommitDiffView {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
pane,
|
pane,
|
||||||
|
tasks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +385,7 @@ impl CommitDiffView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
@@ -465,11 +472,11 @@ impl BasePanel for CommitDiffView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for CommitDiffView {
|
impl Panel for CommitDiffView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().text_sm().child(SharedString::from(format!(
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
"{}/{}",
|
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||||
self.repo_name, self.commit.id
|
|
||||||
)))
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use gpui_component::tag::Tag;
|
|||||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::{Event, EventId, PublicKey};
|
use nostr::prelude::{Event, EventId, PublicKey};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::UserAvatar;
|
use signed_ui::Avatar;
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||||
@@ -71,7 +71,7 @@ pub(crate) fn sidebar_section(
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(name.clone()).picture(picture))
|
||||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})),
|
})),
|
||||||
@@ -137,7 +137,7 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
.child(Avatar::new(author.clone()).picture(picture))
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||||
@@ -12,16 +11,15 @@ use gpui::{
|
|||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||||
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
|
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||||
};
|
};
|
||||||
use signed_ui::{CountBadge, UserAvatar};
|
use signed_ui::{Avatar, CountBadge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use super::{RepoItem, open_repo_item};
|
use super::{RepoItem, open_repo_item, panel_avatar, tab_title};
|
||||||
|
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
|
||||||
const LIST_OVERDRAW: Pixels = px(400.);
|
const LIST_OVERDRAW: Pixels = px(400.);
|
||||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||||
|
|
||||||
@@ -159,27 +157,13 @@ impl InboxView {
|
|||||||
|
|
||||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||||
match event {
|
match event {
|
||||||
BackendEvent::NostrUpdate(updates) => {
|
BackendEvent::RepoUpdates(_) => self.refresh(cx),
|
||||||
let relevant = updates.iter().any(|update| {
|
BackendEvent::Synced => self.refresh(cx),
|
||||||
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
|
||||||
let is_comment = update.kind == Kind::Comment;
|
|
||||||
let is_event_deletion = update.kind == Kind::EventDeletion;
|
|
||||||
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
|
||||||
|
|
||||||
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
|
||||||
});
|
|
||||||
|
|
||||||
if relevant {
|
|
||||||
self.refresh(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||||
debug_assert!(!self.refresh.debouncing());
|
|
||||||
if self.refresh.running() {
|
if self.refresh.running() {
|
||||||
self.refresh.request();
|
self.refresh.request();
|
||||||
return;
|
return;
|
||||||
@@ -196,22 +180,23 @@ impl InboxView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.run_refresh(cx);
|
||||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
|
||||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let Some(me) = backend.read(cx).current_user() else {
|
let (me, client) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(me) = me else {
|
||||||
self.refresh.abort();
|
self.refresh.abort();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
||||||
@@ -408,7 +393,7 @@ impl InboxView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let root = item.root;
|
let root = item.root;
|
||||||
let kind = item.root_kind;
|
let kind = item.root_event.as_ref().map(|event| event.kind);
|
||||||
let address = section.address.clone();
|
let address = section.address.clone();
|
||||||
let first = entry_ix == 0;
|
let first = entry_ix == 0;
|
||||||
let last = entry_ix + 1 == section.entries.len();
|
let last = entry_ix + 1 == section.entries.len();
|
||||||
@@ -559,7 +544,7 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
UserAvatar::new(name.clone())
|
Avatar::new(name.clone())
|
||||||
.picture(profile.picture())
|
.picture(profile.picture())
|
||||||
.xsmall(),
|
.xsmall(),
|
||||||
)
|
)
|
||||||
@@ -576,10 +561,6 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn activity_phrase(kind: Kind) -> &'static str {
|
fn activity_phrase(kind: Kind) -> &'static str {
|
||||||
if kind == COVER_NOTE_KIND {
|
|
||||||
return "added a note";
|
|
||||||
}
|
|
||||||
|
|
||||||
match kind {
|
match kind {
|
||||||
Kind::GitIssue => "opened an issue",
|
Kind::GitIssue => "opened an issue",
|
||||||
Kind::GitPullRequest => "opened a PR",
|
Kind::GitPullRequest => "opened a PR",
|
||||||
@@ -625,7 +606,7 @@ impl BasePanel for InboxView {
|
|||||||
|
|
||||||
impl Panel for InboxView {
|
impl Panel for InboxView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().text_sm().child(SharedString::from("Inbox"))
|
tab_title(panel_avatar("inbox"), SharedString::from("Inbox"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
|||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::activity_subject;
|
use signed_core::activity_subject;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
pub struct IssueDetailView {
|
pub struct IssueDetailView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -65,7 +66,9 @@ impl Panel for IssueDetailView {
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||||
|
|
||||||
div().text_sm().child(short_id)
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
|
|
||||||
|
tab_title(avatar, short_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +153,7 @@ impl Render for IssueDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(Avatar::new(&author).picture(picture))
|
||||||
UserAvatar::new(&author).picture(picture),
|
|
||||||
)
|
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ use gpui_component::{
|
|||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{RepoStatus, activity_subject};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
pub(super) mod detail;
|
pub(super) mod detail;
|
||||||
|
|
||||||
use self::detail::IssueDetailView;
|
use self::detail::IssueDetailView;
|
||||||
|
use super::status_list::{StatusCounts, filter_by_status};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ pub struct IssuesView {
|
|||||||
filter: IssueFilter,
|
filter: IssueFilter,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
visible_issues: Vec<usize>,
|
visible_issues: Vec<usize>,
|
||||||
counts: (usize, usize, usize),
|
counts: StatusCounts,
|
||||||
// A filter change notifies even when the visible rows are unchanged,
|
// A filter change notifies even when the visible rows are unchanged,
|
||||||
// e.g. switching between two empty filters.
|
// e.g. switching between two empty filters.
|
||||||
synced_filter: IssueFilter,
|
synced_filter: IssueFilter,
|
||||||
@@ -85,7 +87,7 @@ impl IssuesView {
|
|||||||
filter: IssueFilter::Open,
|
filter: IssueFilter::Open,
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
visible_issues: Vec::new(),
|
visible_issues: Vec::new(),
|
||||||
counts: (0, 0, 0),
|
counts: StatusCounts::default(),
|
||||||
synced_filter: IssueFilter::Open,
|
synced_filter: IssueFilter::Open,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
@@ -97,25 +99,11 @@ impl IssuesView {
|
|||||||
|
|
||||||
let (visible_issues, counts) = {
|
let (visible_issues, counts) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let mut counts = (0usize, 0usize, 0usize);
|
filter_by_status(
|
||||||
|
&store.issues,
|
||||||
let visible_issues: Vec<usize> = store
|
|issue| store.status_of(issue),
|
||||||
.issues
|
|status| filter.matches(status),
|
||||||
.iter()
|
)
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(ix, issue)| {
|
|
||||||
let status = store.status_of(issue);
|
|
||||||
counts.0 += 1;
|
|
||||||
match status {
|
|
||||||
RepoStatus::Open => counts.1 += 1,
|
|
||||||
RepoStatus::Closed => counts.2 += 1,
|
|
||||||
RepoStatus::Draft | RepoStatus::Applied => {}
|
|
||||||
}
|
|
||||||
filter.matches(status).then_some(ix)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(visible_issues, counts)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let filter_changed = self.synced_filter != filter;
|
let filter_changed = self.synced_filter != filter;
|
||||||
@@ -201,7 +189,7 @@ impl IssuesView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
.child(Avatar::new(author.clone()).picture(picture))
|
||||||
.child(div().child(author)),
|
.child(div().child(author)),
|
||||||
)
|
)
|
||||||
.child(SharedString::from("opened"))
|
.child(SharedString::from("opened"))
|
||||||
@@ -218,7 +206,7 @@ impl IssuesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let (total, open, closed) = self.counts;
|
let counts = self.counts;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.px_4()
|
.px_4()
|
||||||
@@ -234,7 +222,7 @@ impl IssuesView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("all", "All")
|
SegmentButton::new("all", "All")
|
||||||
.icon(Icon::new(CustomIconName::GitIssueDone))
|
.icon(Icon::new(CustomIconName::GitIssueDone))
|
||||||
.count(total)
|
.count(counts.total)
|
||||||
.selected(self.filter == IssueFilter::All)
|
.selected(self.filter == IssueFilter::All)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = IssueFilter::All;
|
this.filter = IssueFilter::All;
|
||||||
@@ -244,7 +232,7 @@ impl IssuesView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("open", "Open")
|
SegmentButton::new("open", "Open")
|
||||||
.icon(Icon::new(CustomIconName::GitIssueOpen))
|
.icon(Icon::new(CustomIconName::GitIssueOpen))
|
||||||
.count(open)
|
.count(counts.open)
|
||||||
.selected(self.filter == IssueFilter::Open)
|
.selected(self.filter == IssueFilter::Open)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = IssueFilter::Open;
|
this.filter = IssueFilter::Open;
|
||||||
@@ -254,7 +242,7 @@ impl IssuesView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("closed", "Closed")
|
SegmentButton::new("closed", "Closed")
|
||||||
.icon(Icon::new(CustomIconName::GitIssueClosed))
|
.icon(Icon::new(CustomIconName::GitIssueClosed))
|
||||||
.count(closed)
|
.count(counts.closed)
|
||||||
.selected(self.filter == IssueFilter::Closed)
|
.selected(self.filter == IssueFilter::Closed)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = IssueFilter::Closed;
|
this.filter = IssueFilter::Closed;
|
||||||
@@ -346,8 +334,11 @@ impl BasePanel for IssuesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for IssuesView {
|
impl Panel for IssuesView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!("{}/issues", self.repo_name)))
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
|
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||||
|
|
||||||
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,56 @@ mod repo;
|
|||||||
mod repo_list;
|
mod repo_list;
|
||||||
mod send_patch;
|
mod send_patch;
|
||||||
pub(crate) mod sidebar;
|
pub(crate) mod sidebar;
|
||||||
|
mod status_list;
|
||||||
pub(crate) mod tree;
|
pub(crate) mod tree;
|
||||||
|
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{AnyElement, App, div};
|
||||||
|
use gpui_component::{Sizable as _, h_flex};
|
||||||
pub use inbox::InboxView;
|
pub use inbox::InboxView;
|
||||||
pub use repo::RepoDetailView;
|
pub use repo::RepoDetailView;
|
||||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
pub use sidebar::SidebarPanel;
|
pub use sidebar::SidebarPanel;
|
||||||
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
|
use signed_ui::{Avatar, PixelAvatar};
|
||||||
|
|
||||||
|
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.items_center()
|
||||||
|
.child(div().flex_shrink_0().child(avatar))
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
|
||||||
|
PixelAvatar::new(seed).xsmall().into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
||||||
|
let seed = store
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|announcement| format!("{}:{}", announcement.owner, announcement.id))
|
||||||
|
.or_else(|| store.addr().map(|addr| addr.to_string()))
|
||||||
|
.or_else(|| {
|
||||||
|
store
|
||||||
|
.path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let owner = store
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
|
||||||
|
|
||||||
|
match owner.and_then(|profile| profile.picture().map(|picture| (profile.name(), picture))) {
|
||||||
|
Some((name, picture)) => Avatar::new(name)
|
||||||
|
.picture(Some(picture))
|
||||||
|
.xsmall()
|
||||||
|
.into_any_element(),
|
||||||
|
None => PixelAvatar::new(seed).xsmall().into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ use signed_core::{
|
|||||||
merge_base_of, pull_request_patch,
|
merge_base_of, pull_request_patch,
|
||||||
};
|
};
|
||||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||||
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
||||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||||
use utils::{relative_time, relative_time_secs};
|
use utils::{relative_time, relative_time_secs};
|
||||||
|
|
||||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||||
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const ROW_HEIGHT: f32 = 37.;
|
const ROW_HEIGHT: f32 = 37.;
|
||||||
|
|
||||||
@@ -78,8 +79,7 @@ pub struct PullRequestDetailView {
|
|||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
/// The dock caches item panels, so without this observer a panel opened
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
/// before the store loaded would stay on its placeholder.
|
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ impl PullRequestDetailView {
|
|||||||
pane,
|
pane,
|
||||||
commit_item_sizes: Rc::new(Vec::new()),
|
commit_item_sizes: Rc::new(Vec::new()),
|
||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,8 +218,6 @@ impl PullRequestDetailView {
|
|||||||
self.current_commit = binding.tip.clone().map(SharedString::from);
|
self.current_commit = binding.tip.clone().map(SharedString::from);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
|
||||||
|
|
||||||
self.load_generation = self.load_generation.wrapping_add(1);
|
self.load_generation = self.load_generation.wrapping_add(1);
|
||||||
let generation = self.load_generation;
|
let generation = self.load_generation;
|
||||||
|
|
||||||
@@ -257,7 +256,6 @@ impl PullRequestDetailView {
|
|||||||
let git = if use_nostr {
|
let git = if use_nostr {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let cache = cache.clone();
|
|
||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
let base = base.clone();
|
let base = base.clone();
|
||||||
@@ -265,7 +263,7 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
Some(
|
Some(
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
|
||||||
|
|
||||||
let workdir = repo
|
let workdir = repo
|
||||||
.workdir()
|
.workdir()
|
||||||
@@ -329,7 +327,7 @@ impl PullRequestDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||||
@@ -346,6 +344,7 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
let panel = cx.new(|cx| {
|
let panel = cx.new(|cx| {
|
||||||
CommitDiffView::new(
|
CommitDiffView::new(
|
||||||
|
self.store.clone(),
|
||||||
worktree,
|
worktree,
|
||||||
self.repo_name.clone(),
|
self.repo_name.clone(),
|
||||||
commit_id.into(),
|
commit_id.into(),
|
||||||
@@ -454,7 +453,7 @@ impl PullRequestDetailView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
UserAvatar::new(author.clone())
|
Avatar::new(author.clone())
|
||||||
.picture(picture),
|
.picture(picture),
|
||||||
)
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
@@ -791,15 +790,17 @@ impl BasePanel for PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for PullRequestDetailView {
|
impl Panel for PullRequestDetailView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
let hex = self.pr_id.to_hex();
|
let hex = self.pr_id.to_hex();
|
||||||
let id = SharedString::from(&hex[..8]);
|
let id = SharedString::from(&hex[..8]);
|
||||||
let title = if self.repo_name.is_empty() {
|
let label = if self.repo_name.is_empty() {
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
SharedString::from(format!("{}/{}", self.repo_name, id))
|
SharedString::from(format!("{}/{}", self.repo_name, id))
|
||||||
};
|
};
|
||||||
div().text_sm().child(title)
|
|
||||||
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use gpui_component::{
|
|||||||
use nostr::prelude::{EventId, Kind};
|
use nostr::prelude::{EventId, Kind};
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{RepoStatus, activity_subject};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
pub(super) mod detail;
|
pub(super) mod detail;
|
||||||
@@ -25,7 +25,9 @@ pub(super) mod new;
|
|||||||
use self::detail::PullRequestDetailView;
|
use self::detail::PullRequestDetailView;
|
||||||
use self::new::open_new_pull_panel;
|
use self::new::open_new_pull_panel;
|
||||||
use super::send_patch::open_send_patch_panel;
|
use super::send_patch::open_send_patch_panel;
|
||||||
|
use super::status_list::{StatusCounts, filter_by_status};
|
||||||
use crate::views::repo::RepoAction;
|
use crate::views::repo::RepoAction;
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
const ROW_HEIGHT: f32 = 73.;
|
const ROW_HEIGHT: f32 = 73.;
|
||||||
|
|
||||||
@@ -59,8 +61,7 @@ pub struct PullRequestsView {
|
|||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
||||||
visible_prs: Vec<usize>,
|
visible_prs: Vec<usize>,
|
||||||
/// Header counts `(total, open, closed, draft, merged)`.
|
counts: StatusCounts,
|
||||||
counts: (usize, usize, usize, usize, usize),
|
|
||||||
// A filter change notifies even when the visible rows are unchanged,
|
// A filter change notifies even when the visible rows are unchanged,
|
||||||
// e.g. switching between two empty filters.
|
// e.g. switching between two empty filters.
|
||||||
synced_filter: PullRequestFilter,
|
synced_filter: PullRequestFilter,
|
||||||
@@ -93,7 +94,7 @@ impl PullRequestsView {
|
|||||||
filter: PullRequestFilter::Open,
|
filter: PullRequestFilter::Open,
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
visible_prs: Vec::new(),
|
visible_prs: Vec::new(),
|
||||||
counts: (0, 0, 0, 0, 0),
|
counts: StatusCounts::default(),
|
||||||
synced_filter: PullRequestFilter::Open,
|
synced_filter: PullRequestFilter::Open,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
@@ -105,32 +106,15 @@ impl PullRequestsView {
|
|||||||
|
|
||||||
let (visible_prs, counts) = {
|
let (visible_prs, counts) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
let roots = store
|
||||||
|
|
||||||
let visible_prs: Vec<usize> = store
|
|
||||||
.pull_requests
|
.pull_requests
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.filter(|pr| pr.kind == Kind::GitPullRequest);
|
||||||
.filter_map(|(ix, pr)| {
|
filter_by_status(
|
||||||
if pr.kind != Kind::GitPullRequest {
|
roots,
|
||||||
return None;
|
|pr| store.status_of(pr),
|
||||||
}
|
|status| filter.matches(status),
|
||||||
|
)
|
||||||
let status = store.status_of(pr);
|
|
||||||
counts.0 += 1;
|
|
||||||
|
|
||||||
match status {
|
|
||||||
RepoStatus::Open => counts.1 += 1,
|
|
||||||
RepoStatus::Closed => counts.2 += 1,
|
|
||||||
RepoStatus::Draft => counts.3 += 1,
|
|
||||||
RepoStatus::Applied => counts.4 += 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
filter.matches(status).then_some(ix)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(visible_prs, counts)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let filter_changed = self.synced_filter != filter;
|
let filter_changed = self.synced_filter != filter;
|
||||||
@@ -219,7 +203,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
.child(Avatar::new(author.clone()).picture(picture))
|
||||||
.child(div().child(author)),
|
.child(div().child(author)),
|
||||||
)
|
)
|
||||||
.child(SharedString::from("opened"))
|
.child(SharedString::from("opened"))
|
||||||
@@ -236,7 +220,7 @@ impl PullRequestsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let (total, open, closed, draft, merged) = self.counts;
|
let counts = self.counts;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.px_4()
|
.px_4()
|
||||||
@@ -252,7 +236,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("all", "All")
|
SegmentButton::new("all", "All")
|
||||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||||
.count(total)
|
.count(counts.total)
|
||||||
.selected(self.filter == PullRequestFilter::All)
|
.selected(self.filter == PullRequestFilter::All)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = PullRequestFilter::All;
|
this.filter = PullRequestFilter::All;
|
||||||
@@ -262,7 +246,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("open", "Open")
|
SegmentButton::new("open", "Open")
|
||||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||||
.count(open)
|
.count(counts.open)
|
||||||
.selected(self.filter == PullRequestFilter::Open)
|
.selected(self.filter == PullRequestFilter::Open)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = PullRequestFilter::Open;
|
this.filter = PullRequestFilter::Open;
|
||||||
@@ -272,7 +256,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("closed", "Closed")
|
SegmentButton::new("closed", "Closed")
|
||||||
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
|
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
|
||||||
.count(closed)
|
.count(counts.closed)
|
||||||
.selected(self.filter == PullRequestFilter::Closed)
|
.selected(self.filter == PullRequestFilter::Closed)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = PullRequestFilter::Closed;
|
this.filter = PullRequestFilter::Closed;
|
||||||
@@ -282,7 +266,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("draft", "Draft")
|
SegmentButton::new("draft", "Draft")
|
||||||
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
|
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
|
||||||
.count(draft)
|
.count(counts.draft)
|
||||||
.selected(self.filter == PullRequestFilter::Draft)
|
.selected(self.filter == PullRequestFilter::Draft)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = PullRequestFilter::Draft;
|
this.filter = PullRequestFilter::Draft;
|
||||||
@@ -292,7 +276,7 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
SegmentButton::new("merged", "Merged")
|
SegmentButton::new("merged", "Merged")
|
||||||
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
|
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
|
||||||
.count(merged)
|
.count(counts.applied)
|
||||||
.selected(self.filter == PullRequestFilter::Merged)
|
.selected(self.filter == PullRequestFilter::Merged)
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
this.filter = PullRequestFilter::Merged;
|
this.filter = PullRequestFilter::Merged;
|
||||||
@@ -350,11 +334,11 @@ impl BasePanel for PullRequestsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for PullRequestsView {
|
impl Panel for PullRequestsView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!(
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
"{}/pull-requests",
|
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||||
self.repo_name
|
|
||||||
)))
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ use signed_git::{
|
|||||||
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
|
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
|
||||||
worktree_commit_range_commits, worktree_commit_range_diff,
|
worktree_commit_range_commits, worktree_commit_range_diff,
|
||||||
};
|
};
|
||||||
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
|
use signed_state::{
|
||||||
|
Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path,
|
||||||
|
};
|
||||||
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
||||||
|
|
||||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
pub struct NewPullRequestView {
|
pub struct NewPullRequestView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -65,6 +68,7 @@ pub struct NewPullRequestView {
|
|||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +77,7 @@ struct ForkCompare {
|
|||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
/// Import namespace of the form `<owner-hex>/<sanitized-id>`.
|
/// Import namespace of the form `<owner-hex>/<sanitized-id>`.
|
||||||
namespace: String,
|
namespace: String,
|
||||||
/// Path of the target repository's GitCache mirror.
|
/// Path of the target repository's mirror.
|
||||||
mirror_path: PathBuf,
|
mirror_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +324,7 @@ impl NewPullRequestView {
|
|||||||
pane,
|
pane,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
|
tasks: Vec::new(),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,7 +392,8 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Branches and the current branch are read off the UI thread, then applied.
|
/// Branches and the current branch are read off the UI thread, then applied.
|
||||||
@@ -416,7 +422,8 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_checkout(
|
fn apply_checkout(
|
||||||
@@ -533,8 +540,7 @@ impl NewPullRequestView {
|
|||||||
let Some((base, _euc)) = self.base_repo(cx) else {
|
let Some((base, _euc)) = self.base_repo(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
let mirror_path = repo_mirror_path(&base);
|
||||||
let mirror_path = cache.repo_path(&base);
|
|
||||||
let namespace = fork_namespace(&announcement);
|
let namespace = fork_namespace(&announcement);
|
||||||
let clone_urls = announcement.clone.clone();
|
let clone_urls = announcement.clone.clone();
|
||||||
|
|
||||||
@@ -561,24 +567,20 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
let task: gpui::Task<Result<(), anyhow::Error>> =
|
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
// The fork and base must share history for a merge-base to exist.
|
|
||||||
// The target's mirror is the object store both sides land in.
|
|
||||||
// `ensure_clone` fetches `origin` when the mirror already exists.
|
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let cache = cache.clone();
|
|
||||||
let base = base.clone();
|
let base = base.clone();
|
||||||
let base_clone_urls = base_clone_urls.clone();
|
let base_clone_urls = base_clone_urls.clone();
|
||||||
let namespace = namespace.clone();
|
let namespace = namespace.clone();
|
||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
let mirror_path = mirror_path.clone();
|
let mirror_path = mirror_path.clone();
|
||||||
async move {
|
async move {
|
||||||
cache.ensure_clone(&base, &base_clone_urls)?;
|
ensure_repo_mirror(&base, &base_clone_urls)?;
|
||||||
|
|
||||||
// Prune stale imports of any fork.
|
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
|
||||||
// Then import this fork's heads under its namespace.
|
|
||||||
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
|
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
|
||||||
|
|
||||||
|
// Fetch the fork's refs and import them under the fork's namespace.
|
||||||
fetch_repo_refs(
|
fetch_repo_refs(
|
||||||
&mirror_path,
|
&mirror_path,
|
||||||
&clone_urls,
|
&clone_urls,
|
||||||
@@ -638,7 +640,8 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
task.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -658,8 +661,6 @@ impl NewPullRequestView {
|
|||||||
let (base_branches, compare_branches) = match result {
|
let (base_branches, compare_branches) = match result {
|
||||||
Ok(branches) => branches,
|
Ok(branches) => branches,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// Keep the previous source, if any.
|
|
||||||
// The error shows inline next to the compare bar.
|
|
||||||
self.error = Some(format!("Could not compare against the fork: {error}").into());
|
self.error = Some(format!("Could not compare against the fork: {error}").into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -673,8 +674,7 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if base_branches.is_empty() {
|
if base_branches.is_empty() {
|
||||||
self.error =
|
self.error = Some("Could not list the target repository's branches.".into());
|
||||||
Some("Could not list the target repository's branches; try again later".into());
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -687,11 +687,8 @@ impl NewPullRequestView {
|
|||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Base defaults to the announced HEAD branch when the mirror has it.
|
|
||||||
// Otherwise `main`, then the first branch.
|
|
||||||
// The fork's `main` is the compare default, else the first branch.
|
|
||||||
// A refresh keeps the previous selection when the branch still exists.
|
|
||||||
let announced = self.store.read(cx).head.clone();
|
let announced = self.store.read(cx).head.clone();
|
||||||
|
|
||||||
let contains =
|
let contains =
|
||||||
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
|
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
|
||||||
|
|
||||||
@@ -790,16 +787,19 @@ impl NewPullRequestView {
|
|||||||
"{base_name} and {compare_name} share no common ancestor"
|
"{base_name} and {compare_name} share no common ancestor"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let commits = worktree_commit_range_commits(
|
let commits = worktree_commit_range_commits(
|
||||||
Path::new(&repo_path),
|
Path::new(&repo_path),
|
||||||
&merge_base,
|
&merge_base,
|
||||||
&compare,
|
&compare,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let diff = worktree_commit_range_diff(
|
let diff = worktree_commit_range_diff(
|
||||||
Path::new(&repo_path),
|
Path::new(&repo_path),
|
||||||
&merge_base,
|
&merge_base,
|
||||||
&compare,
|
&compare,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -835,7 +835,7 @@ impl NewPullRequestView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -916,19 +916,21 @@ impl NewPullRequestView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
task.detach();
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(repo_path) = self.work_path() else {
|
let Some(repo_path) = self.work_path() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let panel = cx.new(|cx| {
|
let panel = cx.new(|cx| {
|
||||||
CommitDiffView::new(
|
CommitDiffView::new(
|
||||||
|
self.store.clone(),
|
||||||
repo_path,
|
repo_path,
|
||||||
self.repo_name.clone(),
|
self.repo_name.clone(),
|
||||||
commit_id.into(),
|
commit_id.into(),
|
||||||
@@ -1328,11 +1330,11 @@ impl BasePanel for NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for NewPullRequestView {
|
impl Panel for NewPullRequestView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!(
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
"{}/new-pull-request",
|
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
||||||
self.repo_name
|
|
||||||
)))
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
|||||||
use nostr::prelude::PublicKey;
|
use nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::ProfileStore;
|
use signed_state::ProfileStore;
|
||||||
use signed_ui::{UserAvatar, middle_truncate};
|
use signed_ui::{Avatar, middle_truncate};
|
||||||
|
|
||||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||||
@@ -173,7 +173,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(name.clone()).picture(picture))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ impl RepoHistoryView {
|
|||||||
// Same display name as the repo detail panel's title.
|
// Same display name as the repo detail panel's title.
|
||||||
let repo_name = repo_display_name(self.store.read(cx));
|
let repo_name = repo_display_name(self.store.read(cx));
|
||||||
|
|
||||||
let panel =
|
let store = self.store.clone();
|
||||||
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
|
let panel = cx.new(|cx| {
|
||||||
|
CommitDiffView::new(store, worktree, repo_name, commit_id.into(), window, cx)
|
||||||
|
});
|
||||||
|
|
||||||
dock_area.update(cx, |dock_area, cx| {
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ use nostr::prelude::{RelayUrl, ToBech32, Url};
|
|||||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||||
use signed_git::FileCommit;
|
use signed_git::FileCommit;
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
|
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind,
|
||||||
RepoListStore, RepoStore, pr_proposes_checkout,
|
ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror,
|
||||||
|
pr_proposes_checkout,
|
||||||
};
|
};
|
||||||
use signed_ui::{
|
use signed_ui::{
|
||||||
CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate,
|
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate,
|
||||||
ref_selector_trigger,
|
ref_selector_trigger,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ use crate::views::pull_requests::new::open_new_pull_panel;
|
|||||||
use crate::views::repo::about::open_about_dialog;
|
use crate::views::repo::about::open_about_dialog;
|
||||||
use crate::views::send_patch::open_send_patch_panel;
|
use crate::views::send_patch::open_send_patch_panel;
|
||||||
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
|
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum RefKind {
|
enum RefKind {
|
||||||
@@ -123,10 +125,26 @@ impl RepoDetailView {
|
|||||||
pub fn new_local(
|
pub fn new_local(
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
local_path: PathBuf,
|
local_path: PathBuf,
|
||||||
|
nip34: Option<Nip34Binding>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let store = cx.new(move |_cx| RepoStore::new_local(local_path));
|
let store = cx.new(move |_cx| RepoStore::new_local(local_path, nip34));
|
||||||
|
Self::new_common(dock_area, store, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local repository whose detected binding matches an announcement.
|
||||||
|
///
|
||||||
|
/// Opens as the announced repository with the local worktree attached.
|
||||||
|
pub fn new_local_announced(
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
announcement: Announcement,
|
||||||
|
local_path: PathBuf,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let addr = announcement.addr();
|
||||||
|
let store = cx.new(move |cx| RepoStore::from_worktree(addr, announcement, local_path, cx));
|
||||||
Self::new_common(dock_area, store, window, cx)
|
Self::new_common(dock_area, store, window, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,24 +312,17 @@ impl RepoDetailView {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let (addr, announcement, local_path) = {
|
let (announcement, local_path) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
(
|
(store.announcement.clone(), store.path.clone())
|
||||||
store.addr().cloned(),
|
|
||||||
store.announcement.clone(),
|
|
||||||
store.path.clone(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local repositories live on disk at their scan path.
|
// A repository with a local worktree shows it directly. An announced one
|
||||||
// No clone step or network refresh applies here.
|
// still loads its announcement and activity from the store, which is
|
||||||
if addr.is_none() {
|
// subscribed to the relays independently.
|
||||||
|
if let Some(local_path) = local_path {
|
||||||
self.repo_started = true;
|
self.repo_started = true;
|
||||||
|
|
||||||
let Some(local_path) = local_path else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
let data = cx
|
let data = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
@@ -343,15 +354,13 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
self.repo_started = true;
|
self.repo_started = true;
|
||||||
|
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
let clone_urls: Vec<Url> = announcement.clone.clone();
|
let clone_urls: Vec<Url> = announcement.clone.clone();
|
||||||
|
|
||||||
let disk = {
|
let disk = {
|
||||||
let cache = cache.clone();
|
|
||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
match cache.open(&addr)? {
|
match open_repo_mirror(&addr)? {
|
||||||
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
|
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
@@ -365,11 +374,10 @@ impl RepoDetailView {
|
|||||||
let data = match disk {
|
let data = match disk {
|
||||||
Ok(Some(data)) => Ok(data),
|
Ok(Some(data)) => Ok(data),
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let cache = cache.clone();
|
|
||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
|
||||||
load_repo_data(&repo)
|
load_repo_data(&repo)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -393,11 +401,10 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let refresh = {
|
let refresh = {
|
||||||
let cache = cache.clone();
|
|
||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let Some(repo) = cache.open(&addr)? else {
|
let Some(repo) = open_repo_mirror(&addr)? else {
|
||||||
return Ok::<_, Error>(None);
|
return Ok::<_, Error>(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1258,6 +1265,24 @@ impl RepoDetailView {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let avatar = PixelAvatar::new(path.clone());
|
let avatar = PixelAvatar::new(path.clone());
|
||||||
|
|
||||||
|
// A repository already bound to a coordinate is not offered for publishing again.
|
||||||
|
let bound = self.store.read(cx).nip34.clone();
|
||||||
|
let action = match bound
|
||||||
|
.as_ref()
|
||||||
|
.filter(|binding| binding.kind == Nip34Kind::Initialized)
|
||||||
|
{
|
||||||
|
Some(binding) => bound_repo_label(binding, cx),
|
||||||
|
None => Button::new("init")
|
||||||
|
.icon(CustomIconName::Init)
|
||||||
|
.label("Initialize on Nostr")
|
||||||
|
.primary()
|
||||||
|
.tooltip("Publish this repository to Nostr")
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.open_init_dialog(window, cx);
|
||||||
|
}))
|
||||||
|
.into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.px_4()
|
.px_4()
|
||||||
.pb_4()
|
.pb_4()
|
||||||
@@ -1295,16 +1320,7 @@ impl RepoDetailView {
|
|||||||
.child(path),
|
.child(path),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(action),
|
||||||
Button::new("init")
|
|
||||||
.icon(CustomIconName::Init)
|
|
||||||
.label("Initialize on Nostr")
|
|
||||||
.primary()
|
|
||||||
.tooltip("Publish this repository to Nostr")
|
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
|
||||||
this.open_init_dialog(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.child(self.render_header_tabs(cx))
|
.child(self.render_header_tabs(cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
@@ -1475,7 +1491,7 @@ impl RepoDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(owner_name.clone()).picture(owner_picture))
|
.child(Avatar::new(owner_name.clone()).picture(owner_picture))
|
||||||
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
||||||
)
|
)
|
||||||
.when(!rest.is_empty(), |this| {
|
.when(!rest.is_empty(), |this| {
|
||||||
@@ -2000,6 +2016,37 @@ pub(super) fn repo_display_name(store: &RepoStore) -> SharedString {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The owner and identifier a local repository is already bound to.
|
||||||
|
fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
|
||||||
|
let store = ProfileStore::global(cx);
|
||||||
|
let profile = binding.owner.map(|pk| store.read(cx).get(&pk));
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child("Initialized by")
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.when_some(profile, |this, profile| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().foreground)
|
||||||
|
.child(Avatar::new(profile.name()).picture(profile.picture()))
|
||||||
|
.child(profile.name()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(binding.identifier.as_deref(), |this, ident| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from(format!("/{ident}"))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
impl BasePanel for RepoDetailView {
|
impl BasePanel for RepoDetailView {
|
||||||
fn panel_name(&self) -> &'static str {
|
fn panel_name(&self) -> &'static str {
|
||||||
"repo"
|
"repo"
|
||||||
@@ -2008,7 +2055,11 @@ impl BasePanel for RepoDetailView {
|
|||||||
|
|
||||||
impl Panel for RepoDetailView {
|
impl Panel for RepoDetailView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
repo_display_name(self.store.read(cx))
|
let store = self.store.read(cx);
|
||||||
|
let name = repo_display_name(store);
|
||||||
|
let avatar = repo_tab_avatar(store, cx);
|
||||||
|
|
||||||
|
tab_title(avatar, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ use gpui_component::{
|
|||||||
};
|
};
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||||
use signed_ui::{SegmentButton, UserAvatar};
|
use signed_ui::{Avatar, SegmentButton};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use super::open_repo_panel;
|
use super::{open_repo_panel, panel_avatar, tab_title};
|
||||||
|
|
||||||
const COLUMNS: usize = 2;
|
const COLUMNS: usize = 2;
|
||||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||||
@@ -289,7 +289,7 @@ impl RepoListView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(UserAvatar::new(owner.name()).picture(owner.picture()))
|
.child(Avatar::new(owner.name()).picture(owner.picture()))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
@@ -389,7 +389,7 @@ impl BasePanel for RepoListView {
|
|||||||
|
|
||||||
impl Panel for RepoListView {
|
impl Panel for RepoListView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().text_sm().child(SharedString::from("Explore"))
|
tab_title(panel_avatar("explore"), SharedString::from("Explore"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use gpui_component::spinner::Spinner;
|
|||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||||
use signed_state::RepoStore;
|
use signed_state::RepoStore;
|
||||||
|
|
||||||
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
pub struct SendPatchView {
|
pub struct SendPatchView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
@@ -205,8 +207,11 @@ impl BasePanel for SendPatchView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for SendPatchView {
|
impl Panel for SendPatchView {
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(SharedString::from(format!("{}/send-patch", self.repo_name)))
|
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||||
|
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||||
|
|
||||||
|
tab_title(avatar, label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use nostr::prelude::*;
|
|||||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||||
use signed_state::Backend;
|
use signed_state::Backend;
|
||||||
|
|
||||||
|
use super::{normalize_server, server_host};
|
||||||
|
|
||||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GraspServersState {
|
pub struct GraspServersState {
|
||||||
@@ -155,7 +157,7 @@ fn render_server_row(
|
|||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.child(display_server(relay)),
|
.child(server_host(relay)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new(format!("remove-relay:{ix}"))
|
Button::new(format!("remove-relay:{ix}"))
|
||||||
@@ -174,14 +176,6 @@ fn render_server_row(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
|
||||||
fn display_server(relay: &RelayUrl) -> SharedString {
|
|
||||||
relay
|
|
||||||
.domain()
|
|
||||||
.map(SharedString::from)
|
|
||||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accepts a bare host as well as a full URL.
|
/// Accepts a bare host as well as a full URL.
|
||||||
fn add_relay(
|
fn add_relay(
|
||||||
state: &Entity<GraspServersState>,
|
state: &Entity<GraspServersState>,
|
||||||
@@ -194,14 +188,8 @@ fn add_relay(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized = if value.contains("://") {
|
match normalize_server(&value) {
|
||||||
value.clone()
|
Some((_, relay)) => {
|
||||||
} else {
|
|
||||||
format!("wss://{value}")
|
|
||||||
};
|
|
||||||
|
|
||||||
match RelayUrl::parse(&normalized) {
|
|
||||||
Ok(relay) => {
|
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
state.error = None;
|
state.error = None;
|
||||||
if !state.grasp_servers.contains(&relay) {
|
if !state.grasp_servers.contains(&relay) {
|
||||||
@@ -210,7 +198,7 @@ fn add_relay(
|
|||||||
});
|
});
|
||||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
||||||
});
|
});
|
||||||
@@ -227,15 +215,19 @@ pub fn load_user_grasp_servers(
|
|||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let (user, client) = {
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.current_user(), backend.client())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(user) = user else {
|
||||||
state.update(cx, |state, _| state.loading_servers = false);
|
state.update(cx, |state, _| state.loading_servers = false);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let client = backend.read(cx).client();
|
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
let result = signed_state::user_grasp_list_servers(client, user).await;
|
let result = signed_state::user_grasp_list_servers(&client, user).await;
|
||||||
|
|
||||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -10,18 +10,20 @@ use dock::{
|
|||||||
};
|
};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||||
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
|
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
|
||||||
};
|
};
|
||||||
use gpui_base::Button as BaseButton;
|
use gpui_base::Button as BaseButton;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::input::InputState;
|
use gpui_component::input::InputState;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
use nostr::prelude::RelayUrl;
|
||||||
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
||||||
|
ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos,
|
||||||
};
|
};
|
||||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||||
|
|
||||||
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
||||||
|
|
||||||
@@ -34,18 +36,31 @@ mod settings_dialog;
|
|||||||
|
|
||||||
use self::onboarding_dialog::OnboardingState;
|
use self::onboarding_dialog::OnboardingState;
|
||||||
|
|
||||||
|
/// The platform that bound a repository, `"nak"` or `"ngit"`.
|
||||||
|
fn local_platform(binding: &Nip34Binding) -> Option<&'static str> {
|
||||||
|
let signals = &binding.signals;
|
||||||
|
|
||||||
|
if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs {
|
||||||
|
Some("nak")
|
||||||
|
} else if signals.nostr_repo_config {
|
||||||
|
Some("ngit")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SidebarPanel {
|
pub struct SidebarPanel {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
inbox: Option<WeakEntity<InboxView>>,
|
inbox: Option<WeakEntity<InboxView>>,
|
||||||
explore: Option<WeakEntity<RepoListView>>,
|
explore: Option<WeakEntity<RepoListView>>,
|
||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
/// The signed-in user's announced repositories, newest first.
|
/// User's announced repositories.
|
||||||
announcements: Arc<Vec<Announcement>>,
|
announcements: Arc<Vec<Announcement>>,
|
||||||
/// Local repositories found by the scan that are not announced yet.
|
/// Local repositories found by the scan that are not announced yet.
|
||||||
local_repos: Arc<Vec<PathBuf>>,
|
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||||
scanning: bool,
|
scanning: bool,
|
||||||
/// Unpushed commit counts per announced repository, shown as row badges.
|
/// Unpushed commit counts per announced repository.
|
||||||
unpushed: HashMap<RepoAddr, usize>,
|
unpushed: HashMap<RepoAddr, usize>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -64,16 +79,12 @@ impl SidebarPanel {
|
|||||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||||
|
|
||||||
if !signer_changed && !signer_required {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if signer_required {
|
if signer_required {
|
||||||
this.banner = pick_banner();
|
this.banner = pick_banner();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
if this.refresh(cx) || signer_required {
|
if this.refresh(cx) || signer_changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -115,30 +126,19 @@ impl SidebarPanel {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let user = backend.read(cx).current_user();
|
let user = backend.read(cx).current_user();
|
||||||
|
|
||||||
let repo_list = RepoListStore::global(cx);
|
let (announcements, local_repos, scanning) = {
|
||||||
let announcements = user
|
let repo_list = RepoListStore::global(cx).read(cx);
|
||||||
.as_ref()
|
|
||||||
.map(|user| repo_list.read(cx).announcements_of(user))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Drop a scanned repository once the user announces it, so it is not listed twice.
|
let announcements = user
|
||||||
let local = LocalReposStore::global(cx);
|
.as_ref()
|
||||||
let scanning = local.read(cx).scanning;
|
.map(|user| repo_list.announcements_of(user))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let local_repos = {
|
let local = LocalReposStore::global(cx).read(cx);
|
||||||
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
|
let local_repos =
|
||||||
local
|
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
||||||
.read(cx)
|
|
||||||
.repos
|
(announcements, local_repos, local.scanning)
|
||||||
.iter()
|
|
||||||
.filter(|path| {
|
|
||||||
let Some(name) = path.file_name() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let announcements_changed = *self.announcements != announcements;
|
let announcements_changed = *self.announcements != announcements;
|
||||||
@@ -258,10 +258,66 @@ impl SidebarPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The detail view offers to publish it to NIP-34.
|
/// The detail view offers to publish it to NIP-34.
|
||||||
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
fn open_local_repo(
|
||||||
|
&mut self,
|
||||||
|
path: PathBuf,
|
||||||
|
nip34: Option<Nip34Binding>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let detail =
|
let detail =
|
||||||
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
|
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, nip34, window, cx));
|
||||||
|
self.add_detail_panel(detail, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local repository whose binding matches an announcement opens as the announced repository.
|
||||||
|
fn open_local_announced(
|
||||||
|
&mut self,
|
||||||
|
announcement: Announcement,
|
||||||
|
path: PathBuf,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let detail = cx.new(|cx| {
|
||||||
|
RepoDetailView::new_local_announced(
|
||||||
|
self.dock_area.clone(),
|
||||||
|
announcement,
|
||||||
|
path,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
self.add_detail_panel(detail, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local repository opens as the announced repository
|
||||||
|
/// when its binding matches one, and as a local-only repository otherwise.
|
||||||
|
fn open_local_entry(
|
||||||
|
&mut self,
|
||||||
|
entry: ResolvedLocalRepo,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let ResolvedLocalRepo {
|
||||||
|
path,
|
||||||
|
nip34,
|
||||||
|
announcement,
|
||||||
|
} = entry;
|
||||||
|
|
||||||
|
if let Some(announcement) = announcement {
|
||||||
|
self.open_local_announced(announcement, path, window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.open_local_repo(path, nip34, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_detail_panel(
|
||||||
|
&mut self,
|
||||||
|
detail: Entity<RepoDetailView>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
self.dock_area
|
self.dock_area
|
||||||
.update(cx, |dock_area, cx| {
|
.update(cx, |dock_area, cx| {
|
||||||
add_center_panel(dock_area, panel_handle(detail), window, cx);
|
add_center_panel(dock_area, panel_handle(detail), window, cx);
|
||||||
@@ -360,11 +416,10 @@ impl SidebarPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders row `ix` of the merged list: an announced repository or a local one.
|
|
||||||
fn render_repo_at(
|
fn render_repo_at(
|
||||||
&self,
|
&self,
|
||||||
announcements: &[Announcement],
|
announcements: &[Announcement],
|
||||||
local_repos: &[PathBuf],
|
local_repos: &[ResolvedLocalRepo],
|
||||||
ix: usize,
|
ix: usize,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
@@ -375,9 +430,9 @@ impl SidebarPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_ix = ix - announcements.len();
|
let local_ix = ix - announcements.len();
|
||||||
let path = &local_repos[local_ix];
|
let entry = &local_repos[local_ix];
|
||||||
|
|
||||||
self.render_local_row(path, cx).into_any_element()
|
self.render_local_row(entry, cx).into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_repo_row(
|
fn render_repo_row(
|
||||||
@@ -386,7 +441,9 @@ impl SidebarPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let name = announcement.name().map(SharedString::from);
|
let name = announcement.name().map(SharedString::from);
|
||||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
// The sidebar rows are compact, so the avatar matches the 16px nav icons.
|
||||||
|
let avatar =
|
||||||
|
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||||
let announcement = announcement.clone();
|
let announcement = announcement.clone();
|
||||||
|
|
||||||
let unpushed = self
|
let unpushed = self
|
||||||
@@ -418,23 +475,52 @@ impl SidebarPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A local repository that is not yet set up for NIP-34, marked with a warning.
|
fn render_local_row(
|
||||||
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
|
&self,
|
||||||
let name = path
|
entry: &ResolvedLocalRepo,
|
||||||
.file_name()
|
cx: &mut Context<Self>,
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
) -> impl IntoElement {
|
||||||
.unwrap_or("Untitled".into());
|
let name = entry.name();
|
||||||
let path = path.to_path_buf();
|
let avatar = local_avatar(entry, cx);
|
||||||
let avatar = PixelAvatar::new(path.to_string_lossy());
|
|
||||||
|
|
||||||
NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
|
let id = format!("local-repo:{}", entry.path.display());
|
||||||
.suffix(
|
let entry = entry.clone();
|
||||||
Icon::new(IconName::TriangleAlert)
|
|
||||||
.small()
|
let suffix: AnyElement = match entry.nip34.as_ref() {
|
||||||
.text_color(cx.theme().warning),
|
Some(binding) => {
|
||||||
)
|
let (label, tooltip) = match binding.kind {
|
||||||
|
Nip34Kind::Initialized => {
|
||||||
|
let platform = local_platform(binding).unwrap_or("Grasp");
|
||||||
|
let tooltip = match platform {
|
||||||
|
"nak" => "Initialized with nak",
|
||||||
|
"ngit" => "Initialized with ngit",
|
||||||
|
_ => "Initialized for NIP-34",
|
||||||
|
};
|
||||||
|
(platform, tooltip)
|
||||||
|
}
|
||||||
|
Nip34Kind::Cloned => ("Cloned", "Cloned buts not initialized"),
|
||||||
|
Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Button::new(id.clone())
|
||||||
|
.xsmall()
|
||||||
|
.child(div().text_size(px(10.)).child(label))
|
||||||
|
.tooltip(tooltip)
|
||||||
|
.secondary()
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
None => Button::new(id.clone())
|
||||||
|
.xsmall()
|
||||||
|
.icon(IconName::TriangleAlert)
|
||||||
|
.tooltip("Not published yet")
|
||||||
|
.ghost()
|
||||||
|
.into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
|
NavItem::new(id, name, avatar)
|
||||||
|
.suffix(suffix)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.open_local_repo(path.clone(), window, cx);
|
this.open_local_entry(entry.clone(), window, cx);
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +548,7 @@ impl SidebarPanel {
|
|||||||
Button::new("user").text().dropdown_caret(true).child(
|
Button::new("user").text().dropdown_caret(true).child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(name.clone()).picture(picture))
|
||||||
.child(div().text_xs().font_semibold().child(name)),
|
.child(div().text_xs().font_semibold().child(name)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -542,6 +628,51 @@ impl SidebarPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
||||||
|
///
|
||||||
|
/// Returns the text to store and the parsed relay URL, or `None` when it is not a valid relay URL.
|
||||||
|
pub(super) fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
|
||||||
|
let text = if input.contains("://") {
|
||||||
|
input.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("wss://{input}")
|
||||||
|
};
|
||||||
|
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
|
||||||
|
pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
||||||
|
relay
|
||||||
|
.domain()
|
||||||
|
.map(SharedString::from)
|
||||||
|
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
|
||||||
|
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
||||||
|
// Matches the other sidebar rows, and the badge below sits at its corner.
|
||||||
|
let avatar = PixelAvatar::new(entry.path.to_string_lossy()).xsmall();
|
||||||
|
|
||||||
|
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||||
|
return avatar.into_any_element();
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = ProfileStore::global(cx);
|
||||||
|
let profile = store.read(cx).get(&owner);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.relative()
|
||||||
|
.child(avatar)
|
||||||
|
.child(
|
||||||
|
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
||||||
|
Avatar::new(profile.name())
|
||||||
|
.picture(profile.picture())
|
||||||
|
.size(px(14.)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_banner() -> SharedString {
|
fn pick_banner() -> SharedString {
|
||||||
let num = SystemTime::now()
|
let num = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ use gpui_component::{
|
|||||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||||
v_flex,
|
v_flex,
|
||||||
};
|
};
|
||||||
use nostr::prelude::RelayUrl;
|
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
|
||||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||||
|
|
||||||
|
use super::{normalize_server, server_host};
|
||||||
|
|
||||||
/// Looks up the option index used to seed a [`SelectState`].
|
/// Looks up the option index used to seed a [`SelectState`].
|
||||||
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
||||||
options
|
options
|
||||||
@@ -51,6 +52,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
|||||||
/// Created once when the dialog opens, so control state survives re-renders.
|
/// Created once when the dialog opens, so control state survives re-renders.
|
||||||
struct SettingsControls {
|
struct SettingsControls {
|
||||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
|
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
font_size: Entity<InputState>,
|
font_size: Entity<InputState>,
|
||||||
@@ -88,6 +90,23 @@ impl SettingsControls {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let event_fetching_options = vec![
|
||||||
|
SelectOption::new("curated", "Curated"),
|
||||||
|
SelectOption::new("uncensored", "Uncensored"),
|
||||||
|
];
|
||||||
|
let event_fetching_value = match settings.event_fetching {
|
||||||
|
EventFetchingStrategy::Curated => "curated",
|
||||||
|
EventFetchingStrategy::Uncensored => "uncensored",
|
||||||
|
};
|
||||||
|
let event_fetching = cx.new(|cx| {
|
||||||
|
SelectState::new(
|
||||||
|
event_fetching_options.clone(),
|
||||||
|
selected_index(&event_fetching_options, event_fetching_value),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
let (light_options, dark_options) = theme_options(cx);
|
let (light_options, dark_options) = theme_options(cx);
|
||||||
let light_theme = cx.new(|cx| {
|
let light_theme = cx.new(|cx| {
|
||||||
SelectState::new(
|
SelectState::new(
|
||||||
@@ -146,6 +165,19 @@ impl SettingsControls {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
subscriptions.push(cx.subscribe(&event_fetching, |_, event, cx| {
|
||||||
|
if let SelectEvent::Confirm(Some(value)) = event {
|
||||||
|
let strategy = match value.as_ref() {
|
||||||
|
"curated" => EventFetchingStrategy::Curated,
|
||||||
|
_ => EventFetchingStrategy::Uncensored,
|
||||||
|
};
|
||||||
|
let store = SettingsStore::global(cx);
|
||||||
|
store.update(cx, |store, cx| {
|
||||||
|
store.edit(|settings| settings.event_fetching = strategy, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||||
if let SelectEvent::Confirm(Some(value)) = event {
|
if let SelectEvent::Confirm(Some(value)) = event {
|
||||||
let store = SettingsStore::global(cx);
|
let store = SettingsStore::global(cx);
|
||||||
@@ -251,6 +283,7 @@ impl SettingsControls {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
appearance,
|
appearance,
|
||||||
|
event_fetching,
|
||||||
light_theme,
|
light_theme,
|
||||||
dark_theme,
|
dark_theme,
|
||||||
font_size,
|
font_size,
|
||||||
@@ -289,8 +322,12 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
|||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
.child(theme_section(&settings, controls, cx))
|
.child(theme_section(&settings, controls, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.child(tab_bar_section(&settings, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(grasp_servers_section(&settings, controls, cx))
|
.child(grasp_servers_section(&settings, controls, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.child(event_fetching_section(controls, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(repositories_section(&settings, controls, cx))
|
.child(repositories_section(&settings, controls, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +340,16 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which relays repository activity is fetched from.
|
||||||
|
fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||||
|
v_flex().w_full().gap_3().child(setting_row(
|
||||||
|
cx,
|
||||||
|
"Event Fetching Strategy",
|
||||||
|
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
|
||||||
|
Select::new(&controls.event_fetching).w_full(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
@@ -373,6 +420,26 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||||
|
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||||
|
v_flex().w_full().gap_3().child(setting_row(
|
||||||
|
cx,
|
||||||
|
"Hide Tab Navigation Buttons",
|
||||||
|
"Enable to show navigation buttons when more than 10 tabs.",
|
||||||
|
Switch::new("hide-tab-navigation")
|
||||||
|
.checked(settings.tab_bar.hide_navigation_buttons)
|
||||||
|
.on_click(move |checked: &bool, _window, cx| {
|
||||||
|
let store = SettingsStore::global(cx);
|
||||||
|
store.update(cx, |store, cx| {
|
||||||
|
store.edit(
|
||||||
|
|settings| settings.tab_bar.hide_navigation_buttons = *checked,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
|
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
|
||||||
fn grasp_servers_section(
|
fn grasp_servers_section(
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
@@ -454,13 +521,11 @@ fn grasp_server_editor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
/// Shows only the host, since grasp servers are entered without a scheme.
|
||||||
/// Matches how the publish dialogs display servers.
|
|
||||||
fn display_server(server: &str) -> SharedString {
|
fn display_server(server: &str) -> SharedString {
|
||||||
RelayUrl::parse(server)
|
match normalize_server(server) {
|
||||||
.ok()
|
Some((_, relay)) => server_host(&relay),
|
||||||
.and_then(|relay| relay.domain().map(|domain| domain.to_owned()))
|
None => SharedString::from(server.to_owned()),
|
||||||
.map(SharedString::from)
|
}
|
||||||
.unwrap_or_else(|| SharedString::from(server.to_owned()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn repositories_section(
|
fn repositories_section(
|
||||||
@@ -568,14 +633,9 @@ fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
|||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let normalized = if value.contains("://") {
|
let Some((normalized, _)) = normalize_server(&value) else {
|
||||||
value
|
|
||||||
} else {
|
|
||||||
format!("wss://{value}")
|
|
||||||
};
|
|
||||||
if RelayUrl::parse(&normalized).is_err() {
|
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
let store = SettingsStore::global(cx);
|
let store = SettingsStore::global(cx);
|
||||||
store.update(cx, |store, cx| {
|
store.update(cx, |store, cx| {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use nostr::prelude::Event;
|
||||||
|
use signed_core::RepoStatus;
|
||||||
|
|
||||||
|
/// Root events counted by their resolved status.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct StatusCounts {
|
||||||
|
pub(crate) total: usize,
|
||||||
|
pub(crate) open: usize,
|
||||||
|
pub(crate) closed: usize,
|
||||||
|
pub(crate) draft: usize,
|
||||||
|
pub(crate) applied: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusCounts {
|
||||||
|
fn record(&mut self, status: RepoStatus) {
|
||||||
|
self.total += 1;
|
||||||
|
match status {
|
||||||
|
RepoStatus::Open => self.open += 1,
|
||||||
|
RepoStatus::Closed => self.closed += 1,
|
||||||
|
RepoStatus::Draft => self.draft += 1,
|
||||||
|
RepoStatus::Applied => self.applied += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of `roots` whose status `keep` accepts, counting every root's status.
|
||||||
|
pub(crate) fn filter_by_status<'a>(
|
||||||
|
roots: impl IntoIterator<Item = &'a Event>,
|
||||||
|
status_of: impl Fn(&Event) -> RepoStatus,
|
||||||
|
keep: impl Fn(RepoStatus) -> bool,
|
||||||
|
) -> (Vec<usize>, StatusCounts) {
|
||||||
|
let mut counts = StatusCounts::default();
|
||||||
|
|
||||||
|
let visible = roots
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, root)| {
|
||||||
|
let status = status_of(root);
|
||||||
|
counts.record(status);
|
||||||
|
keep(status).then_some(index)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(visible, counts)
|
||||||
|
}
|
||||||
+2
-2
@@ -12,9 +12,9 @@ path = "src/main.rs"
|
|||||||
name = "Signed"
|
name = "Signed"
|
||||||
product-name = "Signed"
|
product-name = "Signed"
|
||||||
description = "Nostr Git client for browsing repositories and collaborating"
|
description = "Nostr Git client for browsing repositories and collaborating"
|
||||||
identifier = "su.reya.signed"
|
identifier = "info.reya.signed"
|
||||||
category = "DeveloperTool"
|
category = "DeveloperTool"
|
||||||
version = "0.1.0-alpha"
|
version = "0.2.0-alpha"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 32 KiB |
@@ -17,7 +17,7 @@ contact: https://reya.su
|
|||||||
parts:
|
parts:
|
||||||
signed:
|
signed:
|
||||||
plugin: dump
|
plugin: dump
|
||||||
source: target/release/signed-linux-$ARCH_SUFFIX.tar.gz
|
source: target/release/signed-linux-$TARBALL_ARCH_SUFFIX.tar.gz
|
||||||
|
|
||||||
organize:
|
organize:
|
||||||
# These renames seem to not be necessary, but it's tidier.
|
# These renames seem to not be necessary, but it's tidier.
|
||||||
@@ -54,4 +54,4 @@ parts:
|
|||||||
apps:
|
apps:
|
||||||
signed:
|
signed:
|
||||||
command: usr/bin/signed
|
command: usr/bin/signed
|
||||||
common-id: su.reya.signed
|
common-id: info.reya.signed
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ fn main() {
|
|||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
cx.set_app_identity("su.reya.signed", "Signed");
|
cx.set_app_identity("info.reya.signed", "Signed");
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
|
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
|
||||||
let opts = WindowOptions {
|
let opts = WindowOptions {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## `BackendEvent::SyncProgress`
|
||||||
|
|
||||||
|
File: `crates/signed_state/src/backend.rs`
|
||||||
|
|
||||||
|
Kept intentionally. The progress pipeline (the `SyncProgress` variant, the `sync_progress`
|
||||||
|
field and its accessor, and the progress task in `sync_bootstrap`) is retained for a planned
|
||||||
|
sync progress indicator. No subscriber exists yet. Do not remove it without revisiting that
|
||||||
|
plan.
|
||||||
|
|
||||||
|
## `login` / `logout` family
|
||||||
|
|
||||||
|
File: `crates/signed_state/src/backend.rs`
|
||||||
|
|
||||||
|
No UI path calls these. `import_dialog::open` is an empty stub. Decide whether to
|
||||||
|
delete the family (`login`, `login_with_new_identity`, `login_with_nsec`,
|
||||||
|
`login_with_bunker`, `logout`) or wire the stub to `Backend::login`.
|
||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
# These are generated by script/prepare-flathub
|
# These are generated by script/prepare-flathub
|
||||||
vendor/
|
vendor/
|
||||||
vendor.tar.gz
|
vendor.tar.gz
|
||||||
su.reya.signed.yml
|
info.reya.signed.yml
|
||||||
su.reya.signed.metainfo.xml
|
info.reya.signed.metainfo.xml
|
||||||
release-info.xml
|
release-info.xml
|
||||||
cargo-config.toml
|
cargo-config.toml
|
||||||
build/
|
build/
|
||||||
|
|||||||
+16
-16
@@ -19,14 +19,14 @@ Run the preparation script from the repo root:
|
|||||||
This will:
|
This will:
|
||||||
1. Vendor all Rust dependencies (crates.io + git)
|
1. Vendor all Rust dependencies (crates.io + git)
|
||||||
2. Generate the metainfo.xml with proper release info
|
2. Generate the metainfo.xml with proper release info
|
||||||
3. Create `su.reya.signed.yml` - the Flatpak manifest for Flathub
|
3. Create `info.reya.signed.yml` - the Flatpak manifest for Flathub
|
||||||
|
|
||||||
## Files Generated
|
## Files Generated
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `su.reya.signed.yml` | Main Flatpak manifest (submit this to Flathub) |
|
| `info.reya.signed.yml` | Main Flatpak manifest (submit this to Flathub) |
|
||||||
| `su.reya.signed.metainfo.xml` | AppStream metadata with release info |
|
| `info.reya.signed.metainfo.xml` | AppStream metadata with release info |
|
||||||
| `vendor.tar.gz` | Vendored Rust dependencies |
|
| `vendor.tar.gz` | Vendored Rust dependencies |
|
||||||
| `cargo-config.toml` | Cargo configuration for offline builds |
|
| `cargo-config.toml` | Cargo configuration for offline builds |
|
||||||
| `release-info.xml` | Release info snippet for metainfo |
|
| `release-info.xml` | Release info snippet for metainfo |
|
||||||
@@ -39,13 +39,13 @@ Before submitting to Flathub, test the build:
|
|||||||
cd flathub
|
cd flathub
|
||||||
|
|
||||||
# Build and install locally
|
# Build and install locally
|
||||||
flatpak-builder --user --install --force-clean build su.reya.signed.yml
|
flatpak-builder --user --install --force-clean build info.reya.signed.yml
|
||||||
|
|
||||||
# Test the app
|
# Test the app
|
||||||
flatpak run su.reya.signed
|
flatpak run info.reya.signed
|
||||||
|
|
||||||
# Run the Flathub linter (must pass!)
|
# Run the Flathub linter (must pass!)
|
||||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest su.reya.signed.yml
|
flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest info.reya.signed.yml
|
||||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
|
flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,15 +69,15 @@ git clone --branch=new-pr git@github.com:YOUR_USERNAME/flathub.git
|
|||||||
cd flathub
|
cd flathub
|
||||||
|
|
||||||
# Create a new branch
|
# Create a new branch
|
||||||
git checkout -b su.reya.signed
|
git checkout -b info.reya.signed
|
||||||
|
|
||||||
# Copy ONLY the manifest file from your project
|
# Copy ONLY the manifest file from your project
|
||||||
cp /path/to/signed/flathub/su.reya.signed.yml .
|
cp /path/to/signed/flathub/info.reya.signed.yml .
|
||||||
|
|
||||||
# Commit and push
|
# Commit and push
|
||||||
git add su.reya.signed.yml
|
git add info.reya.signed.yml
|
||||||
git commit -m "Add su.reya.signed"
|
git commit -m "Add info.reya.signed"
|
||||||
git push origin su.reya.signed
|
git push origin info.reya.signed
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Open Pull Request
|
### 3. Open Pull Request
|
||||||
@@ -92,9 +92,9 @@ git push origin su.reya.signed
|
|||||||
|
|
||||||
1. Flathub's automated CI will build your app
|
1. Flathub's automated CI will build your app
|
||||||
2. A maintainer will review your submission
|
2. A maintainer will review your submission
|
||||||
3. Once approved, a new repo `flathub/su.reya.signed` will be created
|
3. Once approved, a new repo `flathub/info.reya.signed` will be created
|
||||||
4. You'll get write access to maintain the app
|
4. You'll get write access to maintain the app
|
||||||
5. Future updates: Push new commits to `flathub/su.reya.signed`
|
5. Future updates: Push new commits to `flathub/info.reya.signed`
|
||||||
|
|
||||||
## Updating the App
|
## Updating the App
|
||||||
|
|
||||||
@@ -104,9 +104,9 @@ To release a new version:
|
|||||||
2. Tag the new release: `git tag -a v0.1.0 -m "Release v0.1.0"`
|
2. Tag the new release: `git tag -a v0.1.0 -m "Release v0.1.0"`
|
||||||
3. Push the tag: `git push origin v0.1.0`
|
3. Push the tag: `git push origin v0.1.0`
|
||||||
4. Run `./script/prepare-flathub` to regenerate
|
4. Run `./script/prepare-flathub` to regenerate
|
||||||
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.signed.git`
|
5. Clone the flathub repo: `git clone https://github.com/flathub/info.reya.signed.git`
|
||||||
6. Update the manifest with new commit/tag and hashes
|
6. Update the manifest with new commit/tag and hashes
|
||||||
7. Submit PR to `flathub/su.reya.signed`
|
7. Submit PR to `flathub/info.reya.signed`
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ To release a new version:
|
|||||||
- Ensure `vendor.tar.gz` is properly extracted before building
|
- Ensure `vendor.tar.gz` is properly extracted before building
|
||||||
|
|
||||||
### Linter complains about metainfo
|
### Linter complains about metainfo
|
||||||
- Ensure `su.reya.signed.metainfo.xml` has at least one `<release>` entry
|
- Ensure `info.reya.signed.metainfo.xml` has at least one `<release>` entry
|
||||||
- Add accessible screenshot URLs if required
|
- Add accessible screenshot URLs if required
|
||||||
|
|
||||||
### Missing dependencies
|
### Missing dependencies
|
||||||
|
|||||||
+6
-3
@@ -11,9 +11,12 @@ ARCH=$(uname -m)
|
|||||||
# Snap uses its own architecture names, which must match the file name and
|
# Snap uses its own architecture names, which must match the file name and
|
||||||
# the architecture asserted inside the .snap. `x86_64`/`aarch64` here would
|
# the architecture asserted inside the .snap. `x86_64`/`aarch64` here would
|
||||||
# produce a file like signed_1.0.0_x86_64.snap that snapd cannot match.
|
# produce a file like signed_1.0.0_x86_64.snap that snapd cannot match.
|
||||||
|
#
|
||||||
|
# The source tarball is named by `script/bundle-linux` after `uname -m`, so it
|
||||||
|
# keeps the x86_64/aarch64 spelling rather than the snap-native one.
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
x86_64|amd64) ARCH_SUFFIX="amd64" ;;
|
x86_64|amd64) ARCH_SUFFIX="amd64"; TARBALL_ARCH_SUFFIX="x86_64" ;;
|
||||||
aarch64|arm64) ARCH_SUFFIX="arm64" ;;
|
aarch64|arm64) ARCH_SUFFIX="arm64"; TARBALL_ARCH_SUFFIX="aarch64" ;;
|
||||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ envsubst < "desktop/resources/signed.desktop.in" > "snap/gui/signed.desktop"
|
|||||||
cp "desktop/resources/icon.png" "snap/gui/signed.png"
|
cp "desktop/resources/icon.png" "snap/gui/signed.png"
|
||||||
|
|
||||||
# Generate snapcraft.yaml with version and architecture
|
# Generate snapcraft.yaml with version and architecture
|
||||||
RELEASE_VERSION="$1" ARCH_SUFFIX="$ARCH_SUFFIX" envsubst < desktop/resources/snap/snapcraft.yaml.in > snap/snapcraft.yaml
|
RELEASE_VERSION="$1" TARBALL_ARCH_SUFFIX="$TARBALL_ARCH_SUFFIX" envsubst < desktop/resources/snap/snapcraft.yaml.in > snap/snapcraft.yaml
|
||||||
|
|
||||||
# Clean previous builds
|
# Clean previous builds
|
||||||
snapcraft clean
|
snapcraft clean
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ archive_match="signed(-[a-zA-Z0-9]+)?-linux-${ARCH_SUFFIX}\.tar\.gz"
|
|||||||
archive=$(ls "target/release" | grep -E ${archive_match})
|
archive=$(ls "target/release" | grep -E ${archive_match})
|
||||||
|
|
||||||
export ARCHIVE="$archive"
|
export ARCHIVE="$archive"
|
||||||
export APP_ID="su.reya.signed"
|
export APP_ID="info.reya.signed"
|
||||||
export APP_NAME="Signed"
|
export APP_NAME="Signed"
|
||||||
export BRANDING_LIGHT="#C6FF4D"
|
export BRANDING_LIGHT="#C6FF4D"
|
||||||
export BRANDING_DARK="#C6FF4D"
|
export BRANDING_DARK="#C6FF4D"
|
||||||
|
|||||||
+10
-10
@@ -4,7 +4,7 @@
|
|||||||
# This script:
|
# This script:
|
||||||
# 1. Vendors all Rust dependencies (crates.io + git)
|
# 1. Vendors all Rust dependencies (crates.io + git)
|
||||||
# 2. Generates release info for metainfo.xml
|
# 2. Generates release info for metainfo.xml
|
||||||
# 3. Creates the Flathub manifest (su.reya.signed.yml)
|
# 3. Creates the Flathub manifest (info.reya.signed.yml)
|
||||||
#
|
#
|
||||||
# Usage: ./script/prepare-flathub [--release-date YYYY-MM-DD]
|
# Usage: ./script/prepare-flathub [--release-date YYYY-MM-DD]
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
APP_ID="su.reya.signed"
|
APP_ID="info.reya.signed"
|
||||||
APP_NAME="Signed"
|
APP_NAME="Signed"
|
||||||
REPO_URL="https://git.reya.su/reya/signed"
|
REPO_URL="https://git.reya.su/reya/signed"
|
||||||
BRANDING_LIGHT="#C6FF4D"
|
BRANDING_LIGHT="#C6FF4D"
|
||||||
@@ -106,7 +106,7 @@ COMMIT=$(git rev-parse HEAD)
|
|||||||
|
|
||||||
# Generate the YAML manifest
|
# Generate the YAML manifest
|
||||||
cat > flathub/${APP_ID}.yml << 'MANIFEST_EOF'
|
cat > flathub/${APP_ID}.yml << 'MANIFEST_EOF'
|
||||||
id: su.reya.signed
|
id: info.reya.signed
|
||||||
runtime: org.freedesktop.Platform
|
runtime: org.freedesktop.Platform
|
||||||
runtime-version: "24.08"
|
runtime-version: "24.08"
|
||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
@@ -153,22 +153,22 @@ modules:
|
|||||||
- install -Dm755 target/release/signed /app/bin/signed
|
- install -Dm755 target/release/signed /app/bin/signed
|
||||||
|
|
||||||
# Install icons
|
# Install icons
|
||||||
- install -Dm644 desktop/resources/icon.png /app/share/icons/hicolor/512x512/apps/su.reya.signed.png
|
- install -Dm644 desktop/resources/icon.png /app/share/icons/hicolor/512x512/apps/info.reya.signed.png
|
||||||
- install -Dm644 desktop/resources/icon@2x.png /app/share/icons/hicolor/1024x1024/apps/su.reya.signed.png
|
- install -Dm644 desktop/resources/icon@2x.png /app/share/icons/hicolor/1024x1024/apps/info.reya.signed.png
|
||||||
|
|
||||||
# Install desktop file
|
# Install desktop file
|
||||||
- |
|
- |
|
||||||
export APP_ID="su.reya.signed"
|
export APP_ID="info.reya.signed"
|
||||||
export APP_ICON="su.reya.signed"
|
export APP_ICON="info.reya.signed"
|
||||||
export APP_NAME="Signed"
|
export APP_NAME="Signed"
|
||||||
export APP_CLI="signed"
|
export APP_CLI="signed"
|
||||||
export APP_ARGS="%U"
|
export APP_ARGS="%U"
|
||||||
export DO_STARTUP_NOTIFY="true"
|
export DO_STARTUP_NOTIFY="true"
|
||||||
envsubst < desktop/resources/signed.desktop.in > signed.desktop
|
envsubst < desktop/resources/signed.desktop.in > signed.desktop
|
||||||
install -Dm644 signed.desktop /app/share/applications/su.reya.signed.desktop
|
install -Dm644 signed.desktop /app/share/applications/info.reya.signed.desktop
|
||||||
|
|
||||||
# Install metainfo (use pre-generated one with release info)
|
# Install metainfo (use pre-generated one with release info)
|
||||||
- install -Dm644 su.reya.signed.metainfo.xml /app/share/metainfo/su.reya.signed.metainfo.xml
|
- install -Dm644 info.reya.signed.metainfo.xml /app/share/metainfo/info.reya.signed.metainfo.xml
|
||||||
|
|
||||||
sources:
|
sources:
|
||||||
# Main source code - specific commit
|
# Main source code - specific commit
|
||||||
@@ -184,7 +184,7 @@ modules:
|
|||||||
|
|
||||||
# Pre-generated metainfo with release info
|
# Pre-generated metainfo with release info
|
||||||
- type: file
|
- type: file
|
||||||
path: su.reya.signed.metainfo.xml
|
path: info.reya.signed.metainfo.xml
|
||||||
sha256: "@METAINFO_SHA256@"
|
sha256: "@METAINFO_SHA256@"
|
||||||
|
|
||||||
# Cargo config for vendoring
|
# Cargo config for vendoring
|
||||||
|
|||||||
+63
-51
@@ -54,66 +54,78 @@ echo "Updating versions..."
|
|||||||
update_version "$WORKSPACE_CARGO"
|
update_version "$WORKSPACE_CARGO"
|
||||||
update_version "$CRATE_CARGO"
|
update_version "$CRATE_CARGO"
|
||||||
|
|
||||||
# Check git status before committing
|
|
||||||
echo "Checking git status..."
|
|
||||||
if git status --porcelain | grep -q .; then
|
|
||||||
echo "Current uncommitted changes:"
|
|
||||||
git status --short
|
|
||||||
|
|
||||||
# Ask user if they want to commit all changes or just version files
|
|
||||||
echo ""
|
|
||||||
echo "Do you want to:"
|
|
||||||
echo "1) Commit all current changes (including the version updates)"
|
|
||||||
echo "2) Commit only the version file changes"
|
|
||||||
echo "3) Abort the release"
|
|
||||||
read -p "Enter choice (1/2/3): " choice
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1)
|
|
||||||
echo "Committing all changes..."
|
|
||||||
git add .
|
|
||||||
;;
|
|
||||||
2)
|
|
||||||
echo "Committing only version file changes..."
|
|
||||||
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
|
||||||
;;
|
|
||||||
3)
|
|
||||||
echo "Release aborted by user"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Invalid choice. Release aborted."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# Only version files were modified, add them specifically
|
|
||||||
echo "Only version files were modified, adding them for commit..."
|
|
||||||
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit the changes
|
|
||||||
COMMIT_MSG="chore: release version $NEW_VERSION"
|
COMMIT_MSG="chore: release version $NEW_VERSION"
|
||||||
|
|
||||||
if git commit -m "$COMMIT_MSG"; then
|
# When the requested version is already set there is nothing to bump or commit,
|
||||||
echo "✓ Committed version changes"
|
# so the current commit is tagged as-is.
|
||||||
|
if git diff --quiet -- "$WORKSPACE_CARGO" "$CRATE_CARGO"; then
|
||||||
|
echo "Version is already $NEW_VERSION, tagging the current commit"
|
||||||
else
|
else
|
||||||
echo "Error: Failed to commit version changes"
|
# Check git status before committing
|
||||||
exit 1
|
echo "Checking git status..."
|
||||||
fi
|
# The version files are always modified at this point, so only ask about other changes.
|
||||||
|
if [ -n "$(git status --porcelain -- . ":(exclude,top)$WORKSPACE_CARGO" ":(exclude,top)$CRATE_CARGO")" ]; then
|
||||||
|
echo "Current uncommitted changes:"
|
||||||
|
git status --short
|
||||||
|
|
||||||
# Push version changes to origin
|
# Ask user if they want to commit all changes or just version files
|
||||||
echo "Pushing version changes to origin..."
|
echo ""
|
||||||
if git push origin master; then
|
echo "Do you want to:"
|
||||||
echo "✓ Successfully pushed version changes to origin"
|
echo "1) Commit all current changes (including the version updates)"
|
||||||
else
|
echo "2) Commit only the version file changes"
|
||||||
echo "Error: Failed to push version changes to origin"
|
echo "3) Abort the release"
|
||||||
exit 1
|
read -p "Enter choice (1/2/3): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo "Committing all changes..."
|
||||||
|
git add .
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "Committing only version file changes..."
|
||||||
|
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "Release aborted by user"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Release aborted."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
# Only version files were modified, add them specifically
|
||||||
|
echo "Only version files were modified, adding them for commit..."
|
||||||
|
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Commit the changes
|
||||||
|
if git commit -m "$COMMIT_MSG"; then
|
||||||
|
echo "✓ Committed version changes"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to commit version changes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push version changes to origin
|
||||||
|
echo "Pushing version changes to origin..."
|
||||||
|
if git push origin master; then
|
||||||
|
echo "✓ Successfully pushed version changes to origin"
|
||||||
|
else
|
||||||
|
echo "Error: Failed to push version changes to origin"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create git tag
|
# Create git tag
|
||||||
TAG_NAME="v$NEW_VERSION"
|
TAG_NAME="v$NEW_VERSION"
|
||||||
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||||
|
echo "Error: tag $TAG_NAME already exists"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
||||||
echo "✓ Created git tag: $TAG_NAME"
|
echo "✓ Created git tag: $TAG_NAME"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user