6 Commits
Author SHA1 Message Date
reya a85b5ee87b fix: snap build
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-14 14:38:25 +07:00
reya e61caf8482 chore: update build information
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-14 14:10:14 +07:00
reya a02e44c0f8 chore: update script
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-14 12:39:04 +07:00
reya b526e13f45 chore: add changelog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-14 12:29:35 +07:00
reya 5e6156066a feat: detect local grasp repositories (#21)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #21
2026-09-14 04:06:10 +00:00
reya a74c166391 chore: remove unnecessary optimization (#20)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #20
2026-09-13 14:48:50 +00:00
62 changed files with 1848 additions and 1267 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -159,7 +159,7 @@ jobs:
id: create_release
uses: akkuman/gitea-release-action@v1
with:
server_url: "https://git.reya.su/"
server_url: "https://git.reya.info/"
repository: "reya/signed"
token: ${{ secrets.GITEA_TOKEN }}
draft: true
+1 -1
View File
@@ -6,7 +6,7 @@ dist/
# Snap and Flatpak local build output
/snap
/su.reya.signed.json
/info.reya.signed.json
/linux-artifacts
# Vendored dependencies + cargo config generated by script/prepare-flathub
-174
View File
@@ -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.
+150 -1
View File
@@ -1 +1,150 @@
.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)`.
## 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
```
+33
View File
@@ -0,0 +1,33 @@
# Changelog
## Unreleased
### Breaking changes
### Added
### Changed
### Fixed
### Removed
### 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
+85 -72
View File
@@ -913,9 +913,9 @@ dependencies = [
[[package]]
name = "bytemuck_derive"
version = "1.12.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"gpui_util",
"indexmap",
@@ -1511,9 +1511,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
@@ -1694,7 +1694,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"proc-macro2",
"quote",
@@ -1865,7 +1865,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.5+spec-1.1.0",
"toml 1.1.6+spec-1.1.0",
"vswhom",
"winreg",
]
@@ -3539,7 +3539,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"accesskit",
"anyhow",
@@ -3747,7 +3747,7 @@ dependencies = [
[[package]]
name = "gpui_apple"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"block",
@@ -3764,13 +3764,14 @@ dependencies = [
"log",
"metal",
"objc",
"objc2 0.6.4",
"parking_lot",
]
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -3816,7 +3817,7 @@ dependencies = [
[[package]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -3848,6 +3849,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-screen-capture-kit",
"objc2-user-notifications",
"parking_lot",
"pathfinder_geometry",
@@ -3862,7 +3864,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -3873,7 +3875,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -3886,7 +3888,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"schemars",
"serde",
@@ -3896,7 +3898,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"log",
@@ -3906,7 +3908,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -3919,6 +3921,10 @@ dependencies = [
"parking_lot",
"raw-window-handle",
"scheduler",
"smallvec",
"unicode-properties",
"unicode-script",
"unicode-segmentation",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -3930,7 +3936,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"bytemuck",
@@ -3956,7 +3962,7 @@ dependencies = [
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -3983,9 +3989,9 @@ dependencies = [
[[package]]
name = "granit-parser"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ec0d45986cd51c847c75c5b69a00852c4fc84d0e5e79f041173f73437d0cdf"
checksum = "48aa83cc6ac4dc610adf5501a5392b4bcc543b2c1f24eaf77469a96e31ec9abe"
dependencies = [
"arraydeque",
"smallvec",
@@ -4260,7 +4266,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"async-compression",
@@ -4280,7 +4286,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"log",
"rustls",
@@ -4723,9 +4729,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
@@ -4740,18 +4746,19 @@ dependencies = [
[[package]]
name = "jiff-core"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
@@ -4980,9 +4987,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.23"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
dependencies = [
"libc",
]
@@ -5070,9 +5077,9 @@ checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
[[package]]
name = "lru-slab"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lsp-types"
@@ -5236,7 +5243,7 @@ dependencies = [
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"bindgen",
@@ -5488,7 +5495,7 @@ dependencies = [
[[package]]
name = "nostr"
version = "0.45.4"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"aes",
"base64 0.22.1",
@@ -5514,7 +5521,7 @@ dependencies = [
[[package]]
name = "nostr-connect"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"async-utility",
"futures-core",
@@ -5528,7 +5535,7 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"nostr",
"opaquerr",
@@ -5537,7 +5544,7 @@ dependencies = [
[[package]]
name = "nostr-gossip"
version = "0.45.0"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"nostr",
"opaquerr",
@@ -5546,7 +5553,7 @@ dependencies = [
[[package]]
name = "nostr-gossip-memory"
version = "0.45.0"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"indexmap",
"lru",
@@ -5558,7 +5565,7 @@ dependencies = [
[[package]]
name = "nostr-lmdb"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"async-utility",
"flatbuffers",
@@ -5573,7 +5580,7 @@ dependencies = [
[[package]]
name = "nostr-memory"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"btreecap",
"nostr",
@@ -5584,7 +5591,7 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#5c669a498e5f8dd1ec5713b11fdb92bcd858ad73"
source = "git+https://github.com/rust-nostr/nostr#b230cecf9dbb38e0228e6fff4544ed9d261326fc"
dependencies = [
"async-utility",
"async-wsocket",
@@ -6071,6 +6078,18 @@ dependencies = [
"objc2-metal 0.3.2",
]
[[package]]
name = "objc2-screen-capture-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
@@ -6311,7 +6330,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"collections",
"serde",
@@ -6618,7 +6637,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.13+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
]
[[package]]
@@ -7099,7 +7118,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"derive_refineable",
]
@@ -7182,7 +7201,7 @@ dependencies = [
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"bytes",
@@ -7560,7 +7579,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"async-task",
"backtrace",
@@ -7981,6 +8000,8 @@ dependencies = [
"gix-worktree-state",
"ignore",
"nostr",
"serde",
"serde_json",
"signed_core",
"tempfile",
]
@@ -8006,6 +8027,7 @@ dependencies = [
"bitcoin_hashes 1.2.0",
"flume 0.11.1",
"futures",
"gix",
"gpui",
"log",
"nostr",
@@ -8117,9 +8139,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.0"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smol"
@@ -8273,7 +8295,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"heapless 0.9.3",
"log",
@@ -8719,18 +8741,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.13.2"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
@@ -8845,9 +8858,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.5+spec-1.1.0"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
@@ -8892,9 +8905,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -9082,9 +9095,9 @@ dependencies = [
[[package]]
name = "tree-sitter-cmake"
version = "0.7.4"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061"
checksum = "d22893fac54133fdb93497efc45713d53a96d93b035288c7a8ab0c2af77c0ddd"
dependencies = [
"cc",
"tree-sitter-language",
@@ -9519,9 +9532,9 @@ dependencies = [
[[package]]
name = "unicode-properties"
version = "0.1.4"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
name = "unicode-script"
@@ -9657,7 +9670,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"perf",
"quote",
@@ -11424,7 +11437,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"anyhow",
"chrono",
@@ -11441,7 +11454,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -11452,7 +11465,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#6ad3c7f278e513875e0bbde5ee935f6c9a342b9c"
source = "git+https://github.com/zed-industries/zed#7960b2a7c9568e90fbe0727332149e5b2a5fd57a"
[[package]]
name = "zune-core"
+1 -6
View File
@@ -39,12 +39,7 @@ impl Assets {
.filter_map(|path| {
let data = Self::get(path.as_ref())?;
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
let content = match data.data {
std::borrow::Cow::Borrowed(bytes) => {
std::str::from_utf8(bytes).ok()?.to_owned()
}
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
};
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
Some((name.to_owned(), content))
})
.collect()
+4 -18
View File
@@ -15,7 +15,7 @@ use gpui_base::dock::{
};
use gpui_base::resize_handle;
use gpui_component::scroll::ScrollbarMode;
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
use gpui_component::{ActiveTheme as _, Side};
use crate::invalid_panel::InvalidPanel;
use crate::tab_panel::SignedTabGroupSkin;
@@ -155,27 +155,13 @@ impl DockAreaRenderer for SignedDockSkin {
cx: &mut App,
) -> AnyElement {
let placement = dock.placement();
let open = dock.is_open();
// A closed left or right dock takes no space.
// A closed bottom dock keeps a strip so its tab bar stays clickable.
if !open && !placement.is_bottom() {
return div().into_any_element();
}
div()
.flex()
.flex_none()
.size_full()
.relative()
.overflow_hidden()
.map(|this| match placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
DockPlacement::Bottom => this.w_full().h(dock.size()),
// Base never builds a dock for the centre.
DockPlacement::Center => this,
})
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
.when(!open && placement.is_bottom(), |this| {
// A closed bottom dock keeps a strip, and that strip is the tab bar.
.when(!dock.is_open() && placement.is_bottom(), |this| {
this.h(TAB_BAR_HEIGHT)
})
.child(content)
+1 -1
View File
@@ -198,7 +198,7 @@ impl SignedTabGroupSkin {
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
DockPlacement::Center => return None,
};
if designated != Some(group.node()) {
return None;
+5 -1
View File
@@ -90,9 +90,13 @@ pub fn nostr_dir() -> &'static PathBuf {
}
/// Returns the path to the local git clone cache, the grasp mirrors.
///
/// The mirrors are disposable and re-cloned from their grasp server on
/// demand, so the cache lives in the OS temp directory for the system to
/// reclaim.
pub fn repos_dir() -> &'static PathBuf {
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
REPOS_DIR.get_or_init(|| std::env::temp_dir().join(APP_NAME_LOWERCASE).join("repos"))
}
pub fn settings_file() -> &'static PathBuf {
-241
View File
@@ -5,244 +5,3 @@ use nostr::prelude::*;
/// 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));
}
}
-87
View File
@@ -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(),
}
);
}
}
-12
View File
@@ -91,18 +91,6 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
.events(roots)
}
/// Cover notes and NIP-32 label events referencing any of the given root events.
/// These are kinds 1624 and 1985, matched via the `#e` tag.
///
/// Because they carry no repository `a` tag, they are fetched by root like comments.
///
/// Batched, like [`statuses_for`].
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
.events(roots)
}
/// A user's grasp list, kind `10317`.
pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new()
+5 -10
View File
@@ -19,8 +19,6 @@ pub struct InboxItem {
pub root: EventId,
/// The root event itself, when it is known locally.
pub root_event: Option<Event>,
/// Kind of the root event, when it is known locally.
pub root_kind: Option<Kind>,
/// Repository the root belongs to, from the root's `a` tag.
pub address: Option<RepoAddr>,
/// Notification events directed at the user, newest first.
@@ -45,13 +43,11 @@ impl InboxItem {
}
pub fn kind(&self) -> Option<Kind> {
self.root_kind.or_else(|| {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
})
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
/// Timestamp of the newest event in the thread.
@@ -202,7 +198,6 @@ where
let mut item = InboxItem {
root,
root_kind: root_event.as_ref().map(|event| event.kind),
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next()),
+1 -3
View File
@@ -1,6 +1,5 @@
pub mod addr;
pub mod annotations;
pub mod clone_url;
pub mod deletions;
pub mod filters;
pub mod inbox;
@@ -9,8 +8,7 @@ pub mod state;
pub mod status;
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url};
pub use annotations::COVER_NOTE_KIND;
pub use deletions::Deletions;
pub use filters::{
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
+2 -9
View File
@@ -41,12 +41,10 @@ pub struct Upstream {
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
/// `None` for the git-URL form.
pub addr: Option<RepoAddr>,
/// Relay hint for the upstream, if the `u` tag carries one.
pub relay_hint: Option<RelayUrl>,
}
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 addr = coordinate
.parse::<Coordinate>()
@@ -55,7 +53,6 @@ impl Upstream {
Self {
raw: raw.to_owned(),
addr,
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
}
}
@@ -317,7 +314,7 @@ impl Announcement {
let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default();
if !raw.is_empty() {
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
upstream = Some(Upstream::parse(raw));
}
}
}
@@ -511,10 +508,6 @@ mod tests {
upstream.raw,
"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!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
+2
View File
@@ -8,6 +8,8 @@ publish.workspace = true
signed_core = { path = "../signed_core" }
nostr.workspace = true
serde.workspace = true
serde_json.workspace = true
gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.56"
gix-worktree-state = "0.34"
-11
View File
@@ -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.
/// `rels` are paths relative to the worktree.
///
+7 -3
View File
@@ -1,6 +1,7 @@
mod cache;
mod diff;
mod history;
mod nip34;
mod patch;
mod remote;
mod repo;
@@ -16,8 +17,11 @@ pub use diff::{
worktree_commit_range_diff,
};
pub use history::{
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit,
worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits,
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_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::{
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,
worktree_ref_state,
};
pub use scan::find_git_repos;
pub use scan::{LocalRepo, find_git_repos};
pub use worktree::{
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag,
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
+454
View File
@@ -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"
)));
}
}
+1 -4
View File
@@ -252,10 +252,7 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
}
/// Apply `edit` to the repository-local configuration and persist it.
///
/// The config file is locked while it is read, edited and written back,
/// like git would when running `git config` or `git remote`.
fn edit_local_config(
pub(crate) fn edit_local_config(
repo: &gix::Repository,
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
) -> Result<()> {
+3 -9
View File
@@ -1,6 +1,6 @@
use std::path::Path;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use crate::history::open_with_cache;
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)?;
index.write(gix::index::write::Options::default())?;
let commit = commit.to_string();
if commit.len() != 40 {
bail!("unexpected initial commit id: {commit}");
}
Ok(commit)
Ok(commit.to_string())
}
/// 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?;
if info.parent_ids().next().is_none() {
let id = info.id().to_string();
return Ok((id.len() == 40).then_some(id));
return Ok(Some(info.id().to_string()));
}
}
+18 -3
View File
@@ -2,12 +2,21 @@ use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::nip34::{Nip34Binding, detect_nip34};
/// Caps nesting so pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// Walk `root` recursively and collect the paths of git repositories below it.
/// `.gitignore` and `.ignore` files are honoured.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
/// A git repository discovered under a scan root.
#[derive(Debug, Clone)]
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() {
return Vec::new();
}
@@ -38,4 +47,10 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
}
roots
.into_iter()
.map(|path| {
let nip34 = detect_nip34(&path);
LocalRepo { path, nip34 }
})
.collect()
}
+17 -8
View File
@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
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("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();
let mut expected = vec![
@@ -578,7 +581,7 @@ fn git_run(dir: &Path, args: &[&str]) {
}
#[test]
fn last_commit_returns_most_recent_change() {
fn worktree_last_commits_returns_most_recent_change() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
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");
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("found");
.into_iter()
.next()
.expect("found")
.1;
assert_eq!(commit.summary, "change a");
assert_eq!(commit.author, "Test Author");
assert!(!commit.id.is_empty());
@@ -621,7 +627,7 @@ fn all_commits_lists_every_commit() {
}
#[test]
fn last_commit_reports_merge_commits() {
fn worktree_last_commits_reports_merge_commits() {
let (dir, repo) = fixture(&[("a.txt", b"base")]);
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.
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("found");
.into_iter()
.next()
.expect("found")
.1;
assert_eq!(
commit.id,
repo.head_id().expect("head").shorten_or_id().to_string()
+3 -13
View File
@@ -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
}
/// Resolve `rev` to a commit id, accepting full refs,
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
/// Resolve `rev` to a commit id, accepting full refs or the bare branch names
/// 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>> {
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
return Some(id);
}
// Branch names arrive bare, like git resolving `main`.
if rev.contains('/') {
return None;
}
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
.ok()
repo.rev_parse_single(rev.as_bytes()).ok()
}
/// Relative paths of all entries in the worktree, files and directories.
+16 -39
View File
@@ -5,32 +5,9 @@ use std::pin::Pin;
use std::sync::{Arc, RwLock};
use nostr_connect::client::AuthUrlHandler;
use nostr_sdk::error::Error as SignerError;
use nostr_sdk::prelude::*;
#[derive(Debug)]
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
impl fmt::Display for UniversalSignerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for UniversalSignerError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&*self.0)
}
}
impl UniversalSignerError {
pub fn new<E>(err: E) -> Self
where
E: Error + Send + Sync + 'static,
{
UniversalSignerError(Box::new(err))
}
}
/// A type-erased signer whose inner signer can be swapped in-place.
#[derive(Clone, Debug)]
pub struct UniversalSigner {
@@ -65,21 +42,21 @@ impl UniversalSigner {
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
}
#[derive(Debug)]
@@ -94,22 +71,22 @@ where
{
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
Box::pin(async move {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
Box::pin(async move {
AsyncSignEvent::sign_event_async(&self.0, unsigned)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
@@ -117,11 +94,11 @@ where
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
@@ -129,17 +106,17 @@ where
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
}
impl AsyncGetPublicKey for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn get_public_key_async(
&self,
@@ -150,7 +127,7 @@ impl AsyncGetPublicKey for UniversalSigner {
}
impl AsyncSignEvent for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn sign_event_async(
&self,
@@ -162,7 +139,7 @@ impl AsyncSignEvent for UniversalSigner {
}
impl AsyncNip44 for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn nip44_encrypt_async<'a>(
&'a self,
+1
View File
@@ -17,6 +17,7 @@ nostr-connect.workspace = true
bitcoin_hashes = "1"
gix.workspace = true
gpui.workspace = true
flume.workspace = true
futures.workspace = true
+40 -37
View File
@@ -5,15 +5,16 @@ use std::time::{Duration, Instant};
use anyhow::{Error, anyhow, bail};
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::nips::nip19::Nip19Coordinate;
use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary;
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 crate::git_store::GitStore;
use crate::git_store::repo_mirror_path;
use crate::inbox::Inbox;
use crate::repos::RepoListStore;
@@ -531,6 +532,7 @@ impl Backend {
let repo_id = repo_id.clone();
let servers = servers.clone();
let refs = refs.clone();
let executor = cx.background_executor().clone();
async move {
push_staged_to_grasps(
&client,
@@ -541,6 +543,7 @@ impl Backend {
&destination,
&owner,
&servers,
&executor,
signed_git::push_main,
)
.await
@@ -686,6 +689,7 @@ impl Backend {
let servers = servers.clone();
let refs = refs.clone();
let head = head.clone();
let executor = cx.background_executor().clone();
async move {
push_staged_to_grasps(
&client,
@@ -696,6 +700,7 @@ impl Backend {
&path,
&owner,
&servers,
&executor,
signed_git::push_all,
)
.await
@@ -739,6 +744,25 @@ impl Backend {
.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"))
})
}
@@ -752,8 +776,7 @@ impl Backend {
announcement: Announcement,
cx: &mut Context<Self>,
) -> Task<Result<PushOutcome, Error>> {
let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&announcement.addr());
let path = repo_mirror_path(&announcement.addr());
self.push_repo_from(announcement, path, None, cx)
}
@@ -854,6 +877,7 @@ impl Backend {
let relays = relays.clone();
let refs = refs.clone();
let head = head.clone();
let executor = cx.background_executor().clone();
async move {
push_staged_to_grasps(
&client,
@@ -864,6 +888,7 @@ impl Backend {
&path,
&owner,
&relays,
&executor,
signed_git::push_all,
)
.await
@@ -1086,10 +1111,6 @@ impl Backend {
self.signer.clone()
}
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
self.pushing_repos.clone()
}
pub fn inbox(&self) -> Entity<Inbox> {
self.inbox.clone()
}
@@ -1102,10 +1123,6 @@ impl Backend {
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>) {
let client = self.client.clone();
let me = self.current_user;
@@ -1498,24 +1515,21 @@ const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
pub struct GraspServerResult {
pub relay: RelayUrl,
pub git_url: String,
/// `None` when the server accepted the data, the reason otherwise.
pub reason: Option<String>,
}
impl GraspServerResult {
fn ok(relay: RelayUrl, git_url: String) -> Self {
fn ok(relay: RelayUrl) -> Self {
Self {
relay,
git_url,
reason: None,
}
}
fn failed(relay: RelayUrl, git_url: String, reason: impl Into<String>) -> Self {
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
Self {
relay,
git_url,
reason: Some(reason.into()),
}
}
@@ -1711,21 +1725,16 @@ async fn push_staged_to_grasps(
path: &Path,
owner: &str,
servers: &[RelayUrl],
executor: &BackgroundExecutor,
push: fn(&Path, &str, &str, &str) -> Result<(), Error>,
) -> PushOutcome {
let mut outcome = PushOutcome::default();
if refs.is_empty() {
return outcome;
}
for relay in servers {
let Some(base) = grasp_base_url(relay) else {
outcome.servers.push(GraspServerResult::failed(
relay.clone(),
relay.to_string(),
"no domain",
));
outcome
.servers
.push(GraspServerResult::failed(relay.clone(), "no domain"));
continue;
};
let git_url = format!("{base}/{owner}/{repo_id}.git");
@@ -1739,7 +1748,7 @@ async fn push_staged_to_grasps(
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
if attempt > 1 {
// 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) =
@@ -1806,11 +1815,9 @@ async fn push_staged_to_grasps(
log::warn!("grasp push failed: {relay}: {reason}");
outcome
.servers
.push(GraspServerResult::failed(relay.clone(), git_url, reason));
.push(GraspServerResult::failed(relay.clone(), reason));
}
None => outcome
.servers
.push(GraspServerResult::ok(relay.clone(), git_url)),
None => outcome.servers.push(GraspServerResult::ok(relay.clone())),
}
}
@@ -1968,13 +1975,9 @@ mod tests {
fn push_outcome_reports_partial_failures() {
let outcome = PushOutcome {
servers: vec![
GraspServerResult::ok(
RelayUrl::parse("wss://gitnostr.com").expect("url"),
"https://gitnostr.com/npub1owner/repo.git".to_owned(),
),
GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")),
GraspServerResult::failed(
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: ...",
),
],
+18 -8
View File
@@ -9,9 +9,10 @@ use settings::{CheckoutRecord, SettingsStore};
use signed_core::{Announcement, RepoAddr};
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::repos::{LocalReposStore, RepoListStore};
use crate::repos::RepoListStore;
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
@@ -93,6 +94,8 @@ pub struct CheckoutsStore {
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
refresh: RefreshGate,
/// True while the timer between a scheduled refresh and its run is pending.
debounce_pending: bool,
local_pending: bool,
/// When the last full pass (with a remote refresh) completed.
///
@@ -163,6 +166,7 @@ impl CheckoutsStore {
push_statuses: HashMap::new(),
requested_head: HashMap::new(),
refresh: RefreshGate::default(),
debounce_pending: false,
local_pending: false,
last_full_sync: None,
_subscriptions: subscriptions,
@@ -279,12 +283,15 @@ impl CheckoutsStore {
/// Re-resolve the associations and the requested statuses.
///
/// Requests arriving while a pass runs fold into a follow-up.
/// Requests arriving while a pass runs fold into a follow-up, requests
/// arriving while the debounce timer is pending are dropped.
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;
}
self.debounce_pending = true;
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
@@ -300,6 +307,7 @@ impl CheckoutsStore {
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
/// the fast local pass.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.debounce_pending = false;
self.refresh.begin();
let records = {
@@ -321,7 +329,7 @@ impl CheckoutsStore {
let announcements = RepoListStore::global(cx).read(cx).announcements.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
.status_requested
@@ -342,7 +350,9 @@ impl CheckoutsStore {
//
// The facts are the origin URL and the root commit, both CLI reads.
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.
if cache_root
.as_ref()
@@ -453,7 +463,7 @@ impl CheckoutsStore {
}
// 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);
return;
}
@@ -515,7 +525,7 @@ impl CheckoutsStore {
this.update(cx, |this, cx| {
// A full pass or a fresh request will apply fresher data
// (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;
}
+29 -20
View File
@@ -1,32 +1,41 @@
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;
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.
#[derive(Debug, Clone)]
pub struct GitStore(GitCache);
/// The root directory of the repository mirrors.
pub(crate) fn repo_mirror_root() -> PathBuf {
git_cache().root().to_path_buf()
}
impl GitStore {
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone()));
store
}
/// The on-disk path of the mirror of `addr`.
pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf {
git_cache().repo_path(addr)
}
pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone())
}
/// Open the mirror of `addr`, if it has been cloned.
pub fn open_repo_mirror(addr: &RepoAddr) -> Result<Option<Repository>> {
git_cache().open(addr)
}
fn new(root: impl Into<PathBuf>) -> Self {
Self(GitCache::new(root.into()))
}
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
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 {
&self.0
pub(crate) fn set_git_cache(root: impl Into<PathBuf>) {
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
log::warn!("git cache root is already set, keeping the first one");
}
}
+20 -4
View File
@@ -2,6 +2,7 @@ mod backend;
mod checkouts;
mod git_store;
mod inbox;
mod local_repos;
mod profile;
mod refresh;
mod repo;
@@ -11,14 +12,17 @@ use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore;
use git_store::set_git_cache;
pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path};
use gpui::{App, AppContext};
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 profile::{Profile, ProfileStore};
pub use refresh::{RefreshGate, RefreshRequest};
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;
#[cfg(not(target_arch = "wasm32"))]
@@ -31,6 +35,7 @@ pub fn init(
// rustls uses the `aws_lc_rs` provider by 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 path = db_path.as_ref().to_path_buf();
new_backend(path)
@@ -38,11 +43,22 @@ pub fn init(
.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);
// Set global stores for the profile
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);
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);
// Set global stores for the checkouts
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
}
@@ -50,10 +66,10 @@ pub fn init(
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) {
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);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
}
+290
View File
@@ -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"));
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ impl Profile {
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> {
+49 -13
View File
@@ -3,14 +3,13 @@
pub struct RefreshGate {
running: bool,
dirty: bool,
debouncing: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run or timer covers the request, start the debounce timer.
/// No run covers the request, start one now.
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,
}
@@ -19,28 +18,20 @@ impl RefreshGate {
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.
/// Folded into a follow-up run while one is in flight, otherwise the
/// caller starts the run itself.
pub fn request(&mut self) -> RefreshRequest {
if self.running {
self.dirty = true;
RefreshRequest::Fold
} else if self.debouncing {
RefreshRequest::Fold
} else {
self.debouncing = true;
RefreshRequest::Schedule
}
}
pub fn begin(&mut self) {
self.debouncing = false;
self.running = true;
}
@@ -55,3 +46,48 @@ impl RefreshGate {
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());
}
}
+20 -19
View File
@@ -11,13 +11,14 @@ use signed_core::{
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
pull_request_patches,
};
use signed_git::Nip34Binding;
use crate::backend::{
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
user_grasp_list_servers,
};
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;
@@ -39,6 +40,8 @@ pub struct RepoStore {
/// Local working copy. The scan path for a local repository, kept when it is
/// later announced so the panel keeps its worktree.
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.
///
/// 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.
open_issue_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>,
/// Non-fatal warning of the last action, if any.
///
@@ -116,6 +115,7 @@ impl RepoStore {
addr: Some(addr),
announcement: hint,
path: None,
nip34: None,
loaded: false,
head: None,
issues: Vec::new(),
@@ -125,7 +125,6 @@ impl RepoStore {
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
version: 0,
last_error: None,
last_warning: None,
last_push_warning: None,
@@ -139,11 +138,12 @@ impl RepoStore {
}
/// 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 {
addr: None,
announcement: None,
path: Some(path),
nip34,
loaded: true,
head: None,
issues: Vec::new(),
@@ -153,7 +153,6 @@ impl RepoStore {
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
version: 0,
last_error: None,
last_warning: None,
last_push_warning: None,
@@ -166,6 +165,18 @@ impl RepoStore {
}
}
/// 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.
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
self.addr = Some(announcement.addr());
@@ -401,9 +412,6 @@ 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 patches);
sort_newest_first(&mut pull_requests);
@@ -522,7 +530,6 @@ impl RepoStore {
this.open_issue_count = open_issue_count;
this.open_pr_count = open_pr_count;
this.loaded = true;
this.version = this.version.wrapping_add(1);
// Comments and statuses without an `a` tag.
// None are addressed to the repository.
@@ -580,10 +587,6 @@ impl RepoStore {
status_of(&self.status_by_root, root)
}
pub fn version(&self) -> u64 {
self.version
}
/// Number of open issues.
///
/// Issues whose resolved status is [`RepoStatus::Open`].
@@ -1210,8 +1213,6 @@ impl RepoStore {
return;
}
let cache = GitStore::global(cx).cache().clone();
let clone_urls: Vec<Url> = self
.announcement
.as_ref()
@@ -1237,7 +1238,7 @@ impl RepoStore {
let root = root.clone();
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
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
+1 -102
View File
@@ -1,116 +1,15 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr_sdk::prelude::*;
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
use signed_git::find_git_repos;
use crate::backend::{Backend, BackendEvent};
use crate::refresh::{RefreshGate, RefreshRequest};
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
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.
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
+7 -24
View File
@@ -8,22 +8,18 @@ use gpui_component::menu::PopupMenu;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
/// A split dropdown button built on `gpui_base::Popover`.
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
/// The action and the caret are caller-supplied elements, so the look stays in the app.
/// This component only owns the popover wiring.
/// An action element next to a caret that opens a [`PopupMenu`].
#[derive(IntoElement)]
pub struct DropdownButton {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
action: Option<AnyElement>,
caret: Option<CaretBuilder>,
menu: Option<MenuBuilder>,
}
type MenuBuilder =
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
impl DropdownButton {
pub fn new(id: impl Into<ElementId>) -> Self {
@@ -32,7 +28,6 @@ impl DropdownButton {
style: StyleRefinement::default(),
anchor: Anchor::TopRight,
action: None,
caret: None,
menu: None,
}
}
@@ -54,14 +49,6 @@ impl DropdownButton {
self.menu = Some(Box::new(builder));
self
}
/// Which corner of the caret the menu anchors to.
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
#[allow(dead_code)] // API knob, current call sites use the default anchor.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
}
impl Styled for DropdownButton {
@@ -91,28 +78,24 @@ impl RenderOnce for DropdownButton {
let menu_state =
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
let caret = self.caret.unwrap_or_else(|| {
let id = popover_id.clone();
Box::new(move |is_open, _, cx| {
let caret = default_caret(id.clone(), cx);
let selected = caret.is_selected();
caret.selected(selected || is_open).into_any_element()
})
});
h_flex()
.id(self.id)
.refine_style(&self.style)
.gap_0p5()
.when_some(self.action, |this, action| this.child(action))
.when_some(self.menu, |this, builder| {
let caret_id = popover_id.clone();
this.child(
Popover::new(popover_id)
.anchor(anchor)
// The menu dismisses itself on outside click or Escape.
// The subscription below closes the popover along with it.
.overlay_closable(false)
.trigger_with(caret)
.trigger_with(move |is_open, _, cx| {
let caret = default_caret(caret_id.clone(), cx);
let selected = caret.is_selected();
caret.selected(selected || is_open).into_any_element()
})
.content(
move |_, window, cx| match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
+27 -16
View File
@@ -1,7 +1,7 @@
use gpui::prelude::*;
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
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.
const GRID_SIZE: usize = 8;
@@ -10,34 +10,36 @@ const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells.
/// A sparse roll still yields a recognizable shape.
/// Each left-half cell is mirrored to a right-half one.
const MIN_FILLED: usize = 5;
/// Side length of the avatar in pixels, no setter.
const AVATAR_SIZE: Pixels = px(16.);
/// A deterministic, offline pixel-art avatar.
/// An 8×8 grid with horizontal mirror symmetry.
/// Seeded from a stable string such as the repository id and owner public key.
/// The same seed always renders the same avatar.
#[derive(IntoElement)]
pub struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
}
impl PixelAvatar {
/// Create an avatar seeded from `seed`.
///
/// The seed should be a stable string unique to the entity the avatar represents.
pub fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::XSmall,
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 {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
@@ -48,16 +50,17 @@ impl RenderOnce for PixelAvatar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let pattern = pattern(self.seed);
let mut cells = Vec::new();
let hue = self.seed as f32 / u64::MAX as f32;
let main = theme.blue.hue(hue);
let shade = if theme.is_dark() {
main.lightness((main.l * 1.6).min(0.95))
} else {
main.lightness((main.l * 0.45).max(0.18))
};
let mut cells = Vec::new();
for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE {
let value = pattern[row * GRID_SIZE + col];
@@ -80,7 +83,7 @@ impl RenderOnce for PixelAvatar {
.grid()
.grid_cols(GRID_SIZE as u16)
.grid_rows(GRID_SIZE as u16)
.size(AVATAR_SIZE)
.size(side_length(self.size))
.flex_shrink_0()
.overflow_hidden()
.bg(main.opacity(0.16))
@@ -88,9 +91,16 @@ impl RenderOnce for PixelAvatar {
}
}
/// 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 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,
}
}
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
@@ -106,18 +116,19 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
}
}
// Sparse rolls can come out nearly empty.
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
if filled < MIN_FILLED {
let half = GRID_SIZE * GRID_SIZE / 2;
let start = (rng.next() % half as u64) as usize;
for offset in 0..half {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % half;
let row = ix / (GRID_SIZE / 2);
let col = ix % (GRID_SIZE / 2);
if pattern[row * GRID_SIZE + col] == 0 {
set_cell(&mut pattern, row, col, 1);
filled += 1;
+45 -3
View File
@@ -1,7 +1,49 @@
use nostr::prelude::*;
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let npub = public_key.to_bech32().unwrap();
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
pub fn shorten_pubkey(public_key: PublicKey) -> String {
let encoded = public_key
.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");
}
}
+2 -8
View File
@@ -1,6 +1,5 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use dock::{BasePanel, DockArea, Panel, PanelEvent};
@@ -21,7 +20,6 @@ use utils::relative_time;
use super::{RepoItem, open_repo_item};
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
const LIST_OVERDRAW: Pixels = px(400.);
const MAX_SUB_ACTIVITIES: usize = 5;
@@ -179,7 +177,6 @@ impl InboxView {
}
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
@@ -196,10 +193,7 @@ impl InboxView {
return;
}
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
}));
self.run_refresh(cx);
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
@@ -408,7 +402,7 @@ impl InboxView {
};
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 first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len();
+12 -25
View File
@@ -24,6 +24,7 @@ use utils::relative_time;
pub(super) mod detail;
use self::detail::IssueDetailView;
use super::status_list::{StatusCounts, filter_by_status};
const ISSUE_ROW_HEIGHT: f32 = 73.;
@@ -52,7 +53,7 @@ pub struct IssuesView {
filter: IssueFilter,
item_sizes: Rc<Vec<Size<Pixels>>>,
visible_issues: Vec<usize>,
counts: (usize, usize, usize),
counts: StatusCounts,
// A filter change notifies even when the visible rows are unchanged,
// e.g. switching between two empty filters.
synced_filter: IssueFilter,
@@ -85,7 +86,7 @@ impl IssuesView {
filter: IssueFilter::Open,
item_sizes: Rc::new(Vec::new()),
visible_issues: Vec::new(),
counts: (0, 0, 0),
counts: StatusCounts::default(),
synced_filter: IssueFilter::Open,
scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
@@ -97,25 +98,11 @@ impl IssuesView {
let (visible_issues, counts) = {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize);
let visible_issues: Vec<usize> = store
.issues
.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)
filter_by_status(
&store.issues,
|issue| store.status_of(issue),
|status| filter.matches(status),
)
};
let filter_changed = self.synced_filter != filter;
@@ -218,7 +205,7 @@ impl IssuesView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let (total, open, closed) = self.counts;
let counts = self.counts;
h_flex()
.px_4()
@@ -234,7 +221,7 @@ impl IssuesView {
.child(
SegmentButton::new("all", "All")
.icon(Icon::new(CustomIconName::GitIssueDone))
.count(total)
.count(counts.total)
.selected(self.filter == IssueFilter::All)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::All;
@@ -244,7 +231,7 @@ impl IssuesView {
.child(
SegmentButton::new("open", "Open")
.icon(Icon::new(CustomIconName::GitIssueOpen))
.count(open)
.count(counts.open)
.selected(self.filter == IssueFilter::Open)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Open;
@@ -254,7 +241,7 @@ impl IssuesView {
.child(
SegmentButton::new("closed", "Closed")
.icon(Icon::new(CustomIconName::GitIssueClosed))
.count(closed)
.count(counts.closed)
.selected(self.filter == IssueFilter::Closed)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Closed;
+1
View File
@@ -8,6 +8,7 @@ mod repo;
mod repo_list;
mod send_patch;
pub(crate) mod sidebar;
mod status_list;
pub(crate) mod tree;
pub use inbox::InboxView;
@@ -26,7 +26,7 @@ use signed_core::{
merge_base_of, pull_request_patch,
};
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 utils::{relative_time, relative_time_secs};
@@ -217,8 +217,6 @@ impl PullRequestDetailView {
self.current_commit = binding.tip.clone().map(SharedString::from);
cx.notify();
let cache = GitStore::global(cx).cache().clone();
self.load_generation = self.load_generation.wrapping_add(1);
let generation = self.load_generation;
@@ -257,7 +255,6 @@ impl PullRequestDetailView {
let git = if use_nostr {
None
} else {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
let base = base.clone();
@@ -265,7 +262,7 @@ impl PullRequestDetailView {
Some(
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
let workdir = repo
.workdir()
+16 -33
View File
@@ -25,6 +25,7 @@ pub(super) mod new;
use self::detail::PullRequestDetailView;
use self::new::open_new_pull_panel;
use super::send_patch::open_send_patch_panel;
use super::status_list::{StatusCounts, filter_by_status};
use crate::views::repo::RepoAction;
const ROW_HEIGHT: f32 = 73.;
@@ -59,8 +60,7 @@ pub struct PullRequestsView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Indices into the store's `pull_requests` matching [`Self::filter`].
visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)`.
counts: (usize, usize, usize, usize, usize),
counts: StatusCounts,
// A filter change notifies even when the visible rows are unchanged,
// e.g. switching between two empty filters.
synced_filter: PullRequestFilter,
@@ -93,7 +93,7 @@ impl PullRequestsView {
filter: PullRequestFilter::Open,
item_sizes: Rc::new(Vec::new()),
visible_prs: Vec::new(),
counts: (0, 0, 0, 0, 0),
counts: StatusCounts::default(),
synced_filter: PullRequestFilter::Open,
scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
@@ -105,32 +105,15 @@ impl PullRequestsView {
let (visible_prs, counts) = {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
let visible_prs: Vec<usize> = store
let roots = store
.pull_requests
.iter()
.enumerate()
.filter_map(|(ix, pr)| {
if pr.kind != Kind::GitPullRequest {
return None;
}
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)
.filter(|pr| pr.kind == Kind::GitPullRequest);
filter_by_status(
roots,
|pr| store.status_of(pr),
|status| filter.matches(status),
)
};
let filter_changed = self.synced_filter != filter;
@@ -236,7 +219,7 @@ impl PullRequestsView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let (total, open, closed, draft, merged) = self.counts;
let counts = self.counts;
h_flex()
.px_4()
@@ -252,7 +235,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("all", "All")
.icon(Icon::new(CustomIconName::GitPullRequest))
.count(total)
.count(counts.total)
.selected(self.filter == PullRequestFilter::All)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::All;
@@ -262,7 +245,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("open", "Open")
.icon(Icon::new(CustomIconName::GitPullRequest))
.count(open)
.count(counts.open)
.selected(self.filter == PullRequestFilter::Open)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Open;
@@ -272,7 +255,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("closed", "Closed")
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
.count(closed)
.count(counts.closed)
.selected(self.filter == PullRequestFilter::Closed)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Closed;
@@ -282,7 +265,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("draft", "Draft")
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
.count(draft)
.count(counts.draft)
.selected(self.filter == PullRequestFilter::Draft)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Draft;
@@ -292,7 +275,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("merged", "Merged")
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
.count(merged)
.count(counts.applied)
.selected(self.filter == PullRequestFilter::Merged)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Merged;
+14 -19
View File
@@ -26,7 +26,9 @@ use signed_git::{
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
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 crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
@@ -73,7 +75,7 @@ struct ForkCompare {
announcement: Announcement,
/// Import namespace of the form `<owner-hex>/<sanitized-id>`.
namespace: String,
/// Path of the target repository's GitCache mirror.
/// Path of the target repository's mirror.
mirror_path: PathBuf,
}
@@ -533,8 +535,7 @@ impl NewPullRequestView {
let Some((base, _euc)) = self.base_repo(cx) else {
return;
};
let cache = GitStore::global(cx).cache().clone();
let mirror_path = cache.repo_path(&base);
let mirror_path = repo_mirror_path(&base);
let namespace = fork_namespace(&announcement);
let clone_urls = announcement.clone.clone();
@@ -561,24 +562,20 @@ impl NewPullRequestView {
let task: gpui::Task<Result<(), anyhow::Error>> =
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
.background_spawn({
let cache = cache.clone();
let base = base.clone();
let base_clone_urls = base_clone_urls.clone();
let namespace = namespace.clone();
let clone_urls = clone_urls.clone();
let mirror_path = mirror_path.clone();
async move {
cache.ensure_clone(&base, &base_clone_urls)?;
ensure_repo_mirror(&base, &base_clone_urls)?;
// Prune stale imports of any fork.
// Then import this fork's heads under its namespace.
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
// Fetch the fork's refs and import them under the fork's namespace.
fetch_repo_refs(
&mirror_path,
&clone_urls,
@@ -658,8 +655,6 @@ impl NewPullRequestView {
let (base_branches, compare_branches) = match result {
Ok(branches) => branches,
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());
cx.notify();
return;
@@ -673,8 +668,7 @@ impl NewPullRequestView {
}
if base_branches.is_empty() {
self.error =
Some("Could not list the target repository's branches; try again later".into());
self.error = Some("Could not list the target repository's branches.".into());
cx.notify();
return;
}
@@ -687,11 +681,8 @@ impl NewPullRequestView {
.map(SharedString::from)
.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 contains =
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
@@ -790,16 +781,19 @@ impl NewPullRequestView {
"{base_name} and {compare_name} share no common ancestor"
)
})?;
let commits = worktree_commit_range_commits(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let diff = worktree_commit_range_diff(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
Ok::<_, anyhow::Error>((merge_base, commits, diff))
}
})
@@ -923,6 +917,7 @@ impl NewPullRequestView {
let Some(repo_path) = self.work_path() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
+79 -33
View File
@@ -26,8 +26,9 @@ use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::FileCommit;
use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
RepoListStore, RepoStore, pr_proposes_checkout,
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind,
ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror,
pr_proposes_checkout,
};
use signed_ui::{
CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate,
@@ -123,10 +124,26 @@ impl RepoDetailView {
pub fn new_local(
dock_area: WeakEntity<DockArea>,
local_path: PathBuf,
nip34: Option<Nip34Binding>,
window: &mut Window,
cx: &mut Context<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)
}
@@ -294,24 +311,17 @@ impl RepoDetailView {
self.error = None;
cx.notify();
let (addr, announcement, local_path) = {
let (announcement, local_path) = {
let store = self.store.read(cx);
(
store.addr().cloned(),
store.announcement.clone(),
store.path.clone(),
)
(store.announcement.clone(), store.path.clone())
};
// Local repositories live on disk at their scan path.
// No clone step or network refresh applies here.
if addr.is_none() {
// A repository with a local worktree shows it directly. An announced one
// still loads its announcement and activity from the store, which is
// subscribed to the relays independently.
if let Some(local_path) = local_path {
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 data = cx
.background_spawn(async move {
@@ -343,15 +353,13 @@ impl RepoDetailView {
self.repo_started = true;
let cache = GitStore::global(cx).cache().clone();
let addr = announcement.addr();
let clone_urls: Vec<Url> = announcement.clone.clone();
let disk = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
match cache.open(&addr)? {
match open_repo_mirror(&addr)? {
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
None => Ok(None),
}
@@ -365,11 +373,10 @@ impl RepoDetailView {
let data = match disk {
Ok(Some(data)) => Ok(data),
Ok(None) => {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
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)
})
.await
@@ -393,11 +400,10 @@ impl RepoDetailView {
}
let refresh = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
let Some(repo) = cache.open(&addr)? else {
let Some(repo) = open_repo_mirror(&addr)? else {
return Ok::<_, Error>(None);
};
@@ -1258,6 +1264,24 @@ impl RepoDetailView {
.unwrap_or_default();
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()
.px_4()
.pb_4()
@@ -1295,16 +1319,7 @@ impl RepoDetailView {
.child(path),
),
)
.child(
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(action),
)
.child(self.render_header_tabs(cx))
.into_any_element()
@@ -2000,6 +2015,37 @@ pub(super) fn repo_display_name(store: &RepoStore) -> SharedString {
.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(UserAvatar::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 {
fn panel_name(&self) -> &'static str {
"repo"
@@ -8,6 +8,8 @@ use nostr::prelude::*;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
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.
#[derive(Default)]
pub struct GraspServersState {
@@ -155,7 +157,7 @@ fn render_server_row(
.text_color(cx.theme().muted_foreground)
.text_sm()
.rounded(cx.theme().radius)
.child(display_server(relay)),
.child(server_host(relay)),
)
.child(
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.
fn add_relay(
state: &Entity<GraspServersState>,
@@ -194,14 +188,8 @@ fn add_relay(
return;
}
let normalized = if value.contains("://") {
value.clone()
} else {
format!("wss://{value}")
};
match RelayUrl::parse(&normalized) {
Ok(relay) => {
match normalize_server(&value) {
Some((_, relay)) => {
state.update(cx, |state, _| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
@@ -210,7 +198,7 @@ fn add_relay(
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
Err(_) => {
None => {
state.update(cx, |state, _| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
});
+185 -51
View File
@@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -10,16 +10,18 @@ use dock::{
};
use gpui::prelude::*;
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,
};
use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
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::{
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};
@@ -34,18 +36,31 @@ mod settings_dialog;
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 {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>,
banner: SharedString,
/// The signed-in user's announced repositories, newest first.
/// User's announced repositories.
announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<PathBuf>>,
local_repos: Arc<Vec<ResolvedLocalRepo>>,
scanning: bool,
/// Unpushed commit counts per announced repository, shown as row badges.
/// Unpushed commit counts per announced repository.
unpushed: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>,
}
@@ -115,30 +130,21 @@ impl SidebarPanel {
let backend = Backend::global(cx);
let user = backend.read(cx).current_user();
let repo_list = RepoListStore::global(cx);
let announcements = user
.as_ref()
.map(|user| repo_list.read(cx).announcements_of(user))
.unwrap_or_default();
let (announcements, local_repos, scanning) = {
let repo_list = RepoListStore::global(cx);
let repo_list = repo_list.read(cx);
// Drop a scanned repository once the user announces it, so it is not listed twice.
let local = LocalReposStore::global(cx);
let scanning = local.read(cx).scanning;
let announcements = user
.as_ref()
.map(|user| repo_list.announcements_of(user))
.unwrap_or_default();
let local_repos = {
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
local
.read(cx)
.repos
.iter()
.filter(|path| {
let Some(name) = path.file_name() else {
return true;
};
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
})
.cloned()
.collect()
let local = LocalReposStore::global(cx);
let local = local.read(cx);
let local_repos =
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
(announcements, local_repos, local.scanning)
};
let announcements_changed = *self.announcements != announcements;
@@ -258,10 +264,66 @@ impl SidebarPanel {
}
/// 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 =
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
.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(detail), window, cx);
@@ -360,11 +422,10 @@ impl SidebarPanel {
})
}
/// Renders row `ix` of the merged list: an announced repository or a local one.
fn render_repo_at(
&self,
announcements: &[Announcement],
local_repos: &[PathBuf],
local_repos: &[ResolvedLocalRepo],
ix: usize,
cx: &mut Context<Self>,
) -> AnyElement {
@@ -375,9 +436,9 @@ impl SidebarPanel {
}
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(
@@ -418,23 +479,52 @@ impl SidebarPanel {
)
}
/// A local repository that is not yet set up for NIP-34, marked with a warning.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or("Untitled".into());
let path = path.to_path_buf();
let avatar = PixelAvatar::new(path.to_string_lossy());
fn render_local_row(
&self,
entry: &ResolvedLocalRepo,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = entry.name();
let avatar = local_avatar(entry, cx);
NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
.suffix(
Icon::new(IconName::TriangleAlert)
.small()
.text_color(cx.theme().warning),
)
let id = format!("local-repo:{}", entry.path.display());
let entry = entry.clone();
let suffix: AnyElement = match entry.nip34.as_ref() {
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| {
this.open_local_repo(path.clone(), window, cx);
this.open_local_entry(entry.clone(), window, cx);
}))
}
@@ -542,6 +632,50 @@ 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 {
let avatar = PixelAvatar::new(entry.path.to_string_lossy());
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(
UserAvatar::new(profile.name())
.picture(profile.picture())
.size(px(14.)),
),
)
.into_any_element()
}
fn pick_banner() -> SharedString {
let num = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -18,10 +18,11 @@ use gpui_component::{
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
v_flex,
};
use nostr::prelude::RelayUrl;
use settings::{AppearanceMode, Settings, SettingsStore};
use signed_ui::{SelectOption, setting_block, setting_row};
use super::{normalize_server, server_host};
/// Looks up the option index used to seed a [`SelectState`].
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
options
@@ -454,13 +455,11 @@ fn grasp_server_editor(
}
/// 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 {
RelayUrl::parse(server)
.ok()
.and_then(|relay| relay.domain().map(|domain| domain.to_owned()))
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(server.to_owned()))
match normalize_server(server) {
Some((_, relay)) => server_host(&relay),
None => SharedString::from(server.to_owned()),
}
}
fn repositories_section(
@@ -568,14 +567,9 @@ fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
if value.is_empty() {
return;
}
let normalized = if value.contains("://") {
value
} else {
format!("wss://{value}")
};
if RelayUrl::parse(&normalized).is_err() {
let Some((normalized, _)) = normalize_server(&value) else {
return;
}
};
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
+45
View File
@@ -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)
}
+1 -1
View File
@@ -12,7 +12,7 @@ path = "src/main.rs"
name = "Signed"
product-name = "Signed"
description = "Nostr Git client for browsing repositories and collaborating"
identifier = "su.reya.signed"
identifier = "info.reya.signed"
category = "DeveloperTool"
version = "0.1.0-alpha"
out-dir = "../dist"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+2 -2
View File
@@ -17,7 +17,7 @@ contact: https://reya.su
parts:
signed:
plugin: dump
source: target/release/signed-linux-$ARCH_SUFFIX.tar.gz
source: target/release/signed-linux-$TARBALL_ARCH_SUFFIX.tar.gz
organize:
# These renames seem to not be necessary, but it's tidier.
@@ -54,4 +54,4 @@ parts:
apps:
signed:
command: usr/bin/signed
common-id: su.reya.signed
common-id: info.reya.signed
+1 -1
View File
@@ -78,7 +78,7 @@ fn main() {
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 opts = WindowOptions {
+18
View File
@@ -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
View File
@@ -2,8 +2,8 @@
# These are generated by script/prepare-flathub
vendor/
vendor.tar.gz
su.reya.signed.yml
su.reya.signed.metainfo.xml
info.reya.signed.yml
info.reya.signed.metainfo.xml
release-info.xml
cargo-config.toml
build/
+16 -16
View File
@@ -19,14 +19,14 @@ Run the preparation script from the repo root:
This will:
1. Vendor all Rust dependencies (crates.io + git)
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
| File | Purpose |
|------|---------|
| `su.reya.signed.yml` | Main Flatpak manifest (submit this to Flathub) |
| `su.reya.signed.metainfo.xml` | AppStream metadata with release info |
| `info.reya.signed.yml` | Main Flatpak manifest (submit this to Flathub) |
| `info.reya.signed.metainfo.xml` | AppStream metadata with release info |
| `vendor.tar.gz` | Vendored Rust dependencies |
| `cargo-config.toml` | Cargo configuration for offline builds |
| `release-info.xml` | Release info snippet for metainfo |
@@ -39,13 +39,13 @@ Before submitting to Flathub, test the build:
cd flathub
# 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
flatpak run su.reya.signed
flatpak run info.reya.signed
# 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
```
@@ -69,15 +69,15 @@ git clone --branch=new-pr git@github.com:YOUR_USERNAME/flathub.git
cd flathub
# Create a new branch
git checkout -b su.reya.signed
git checkout -b info.reya.signed
# 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
git add su.reya.signed.yml
git commit -m "Add su.reya.signed"
git push origin su.reya.signed
git add info.reya.signed.yml
git commit -m "Add info.reya.signed"
git push origin info.reya.signed
```
### 3. Open Pull Request
@@ -92,9 +92,9 @@ git push origin su.reya.signed
1. Flathub's automated CI will build your app
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
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
@@ -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"`
3. Push the tag: `git push origin v0.1.0`
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
7. Submit PR to `flathub/su.reya.signed`
7. Submit PR to `flathub/info.reya.signed`
## Troubleshooting
@@ -115,7 +115,7 @@ To release a new version:
- Ensure `vendor.tar.gz` is properly extracted before building
### 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
### Missing dependencies
+6 -3
View File
@@ -11,9 +11,12 @@ ARCH=$(uname -m)
# Snap uses its own architecture names, which must match the file name and
# 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.
#
# 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
x86_64|amd64) ARCH_SUFFIX="amd64" ;;
aarch64|arm64) ARCH_SUFFIX="arm64" ;;
x86_64|amd64) ARCH_SUFFIX="amd64"; TARBALL_ARCH_SUFFIX="x86_64" ;;
aarch64|arm64) ARCH_SUFFIX="arm64"; TARBALL_ARCH_SUFFIX="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
@@ -27,7 +30,7 @@ envsubst < "desktop/resources/signed.desktop.in" > "snap/gui/signed.desktop"
cp "desktop/resources/icon.png" "snap/gui/signed.png"
# 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
snapcraft clean
+1 -1
View File
@@ -16,7 +16,7 @@ archive_match="signed(-[a-zA-Z0-9]+)?-linux-${ARCH_SUFFIX}\.tar\.gz"
archive=$(ls "target/release" | grep -E ${archive_match})
export ARCHIVE="$archive"
export APP_ID="su.reya.signed"
export APP_ID="info.reya.signed"
export APP_NAME="Signed"
export BRANDING_LIGHT="#C6FF4D"
export BRANDING_DARK="#C6FF4D"
+10 -10
View File
@@ -4,7 +4,7 @@
# This script:
# 1. Vendors all Rust dependencies (crates.io + git)
# 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]
@@ -12,7 +12,7 @@ set -euo pipefail
cd "$(dirname "$0")/.."
# Configuration
APP_ID="su.reya.signed"
APP_ID="info.reya.signed"
APP_NAME="Signed"
REPO_URL="https://git.reya.su/reya/signed"
BRANDING_LIGHT="#C6FF4D"
@@ -106,7 +106,7 @@ COMMIT=$(git rev-parse HEAD)
# Generate the YAML manifest
cat > flathub/${APP_ID}.yml << 'MANIFEST_EOF'
id: su.reya.signed
id: info.reya.signed
runtime: org.freedesktop.Platform
runtime-version: "24.08"
sdk: org.freedesktop.Sdk
@@ -153,22 +153,22 @@ modules:
- install -Dm755 target/release/signed /app/bin/signed
# Install icons
- install -Dm644 desktop/resources/icon.png /app/share/icons/hicolor/512x512/apps/su.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.png /app/share/icons/hicolor/512x512/apps/info.reya.signed.png
- install -Dm644 desktop/resources/icon@2x.png /app/share/icons/hicolor/1024x1024/apps/info.reya.signed.png
# Install desktop file
- |
export APP_ID="su.reya.signed"
export APP_ICON="su.reya.signed"
export APP_ID="info.reya.signed"
export APP_ICON="info.reya.signed"
export APP_NAME="Signed"
export APP_CLI="signed"
export APP_ARGS="%U"
export DO_STARTUP_NOTIFY="true"
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 -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:
# Main source code - specific commit
@@ -184,7 +184,7 @@ modules:
# Pre-generated metainfo with release info
- type: file
path: su.reya.signed.metainfo.xml
path: info.reya.signed.metainfo.xml
sha256: "@METAINFO_SHA256@"
# Cargo config for vendoring
+63 -51
View File
@@ -54,66 +54,78 @@ echo "Updating versions..."
update_version "$WORKSPACE_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"
if git commit -m "$COMMIT_MSG"; then
echo "✓ Committed version changes"
# When the requested version is already set there is nothing to bump or commit,
# 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
echo "Error: Failed to commit version changes"
exit 1
fi
# Check git status before committing
echo "Checking git status..."
# 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
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
# 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
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
# Create git tag
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
echo "✓ Created git tag: $TAG_NAME"
else