Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a87030e929 | ||
|
|
272ddf2674 | ||
|
|
fa50e9f153 | ||
|
|
da22d26019 | ||
|
|
0ff6740a9e | ||
|
|
e2bce4e074 | ||
|
|
a85b5ee87b | ||
|
|
e61caf8482 | ||
|
|
a02e44c0f8 | ||
|
|
b526e13f45 | ||
|
|
5e6156066a | ||
|
|
a74c166391 | ||
|
|
f6b8a5e133 | ||
|
|
40deb9db66 |
@@ -159,7 +159,7 @@ jobs:
|
||||
id: create_release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
server_url: "https://git.reya.su/"
|
||||
server_url: "https://git.reya.info/"
|
||||
repository: "reya/signed"
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
draft: true
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ dist/
|
||||
|
||||
# Snap and Flatpak local build output
|
||||
/snap
|
||||
/su.reya.signed.json
|
||||
/info.reya.signed.json
|
||||
/linux-artifacts
|
||||
|
||||
# Vendored dependencies + cargo config generated by script/prepare-flathub
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
# Rust coding guidelines
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Avoid creative additions unless explicitly requested
|
||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||
Example:
|
||||
```rust
|
||||
executor.spawn({
|
||||
let task_ran = task_ran.clone();
|
||||
async move {
|
||||
*task_ran.borrow_mut() = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
# Timers in tests
|
||||
|
||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||
|
||||
# GPUI
|
||||
|
||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||
|
||||
## Context
|
||||
|
||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||
|
||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||
|
||||
## `Window`
|
||||
|
||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||
|
||||
## Entities
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||
|
||||
* `thing.entity_id()` returns `EntityId`
|
||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||
* `thing.read(cx: &App)` returns `&T`.
|
||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||
|
||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||
|
||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||
|
||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All use of entities and UI rendering occurs on a single foreground thread.
|
||||
|
||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||
|
||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||
|
||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||
|
||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||
|
||||
* Awaiting the task in some other async context.
|
||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `Fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `git_ui: Add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
|
||||
# Rules Hygiene
|
||||
|
||||
These `.rules` files are read by every agent session. Keep them high-signal.
|
||||
|
||||
## After any agentic session
|
||||
If you discover a non-obvious pattern that would help future sessions, include a **"Suggested .rules additions"** heading in your PR description with the proposed text. Do **not** edit `.rules` inline during normal feature/fix work. Reviewers decide what gets merged.
|
||||
|
||||
## High bar for new rules
|
||||
Editing or clarifying existing rules is always welcome. New rules must meet **all three** criteria:
|
||||
1. **Non-obvious** — someone familiar with the codebase would still get it wrong without the rule.
|
||||
2. **Repeatedly encountered** — it came up more than once (multiple hits in one session counts).
|
||||
3. **Specific enough to act on** — a concrete instruction, not a vague principle.
|
||||
|
||||
Rules that apply to a single crate belong in that crate's own `.rules` file, not the repo root.
|
||||
|
||||
## What NOT to put in `.rules`
|
||||
Avoid architectural descriptions of a crate (module layout, data flow, key types). These go stale fast and the agent can gather them by reading the code. Rules should be **traps to avoid**, not **maps to follow**.
|
||||
|
||||
## No drive-by additions
|
||||
Rules emerge from validated patterns, not one-off observations. The workflow is:
|
||||
1. Agent notes a pattern during a session.
|
||||
2. Team validates the pattern in code review.
|
||||
3. A dedicated commit adds the rule with context on *why* it exists.
|
||||
@@ -1 +1,152 @@
|
||||
.rules
|
||||
# Rust coding guidelines
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Avoid creative additions unless explicitly requested
|
||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||
Example:
|
||||
```rust
|
||||
executor.spawn({
|
||||
let task_ran = task_ran.clone();
|
||||
async move {
|
||||
*task_ran.borrow_mut() = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
# Timers in tests
|
||||
|
||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||
|
||||
# GPUI
|
||||
|
||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||
|
||||
## Context
|
||||
|
||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||
|
||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||
|
||||
## `Window`
|
||||
|
||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||
|
||||
## Entities
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||
|
||||
* `thing.entity_id()` returns `EntityId`
|
||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||
* `thing.read(cx: &App)` returns `&T`.
|
||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||
|
||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||
|
||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||
|
||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All use of entities and UI rendering occurs on a single foreground thread.
|
||||
|
||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||
|
||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||
|
||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||
|
||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||
|
||||
* Awaiting the task in some other async context.
|
||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Added
|
||||
|
||||
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
|
||||
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
|
||||
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
||||
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
||||
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
||||
- Connect to bootstrap relays on demand instead of at startup
|
||||
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
|
||||
- Prefetch the 500 most recent profiles at startup instead of 200
|
||||
|
||||
### Fixed
|
||||
|
||||
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
|
||||
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
|
||||
- Fix background tasks outliving their view, so a closed repository panel or pull request view stops fetching and publishing on its own
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
|
||||
|
||||
### Deprecated
|
||||
|
||||
## v0.1.0-alpha - 2026/09/14
|
||||
|
||||
Initial alpha release.
|
||||
|
||||
### Added
|
||||
|
||||
- Create and unlock a passphrase-protected Nostr identity
|
||||
- Browse and search NIP-34 repositories with All, Popular and Recent filters
|
||||
- Scan local Git repositories and detect their NIP-34 and GRASP bindings
|
||||
- Create, publish, clone and open repositories
|
||||
- Repository view with files, commit history, branches and tags
|
||||
- Colour-coded commit diffs
|
||||
- Issues and pull requests with discussions
|
||||
- Send patches and push branches
|
||||
- Inbox for repository activity
|
||||
- Settings for appearance, theme, GRASP servers and repository scan paths
|
||||
- Release and packaging infrastructure
|
||||
Generated
+633
-624
File diff suppressed because it is too large
Load Diff
+6
-9
@@ -4,21 +4,18 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0-alpha"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.6" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" }
|
||||
|
||||
# GPUI Kit
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "0.6.6"
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
@@ -77,9 +77,9 @@
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#CECECE",
|
||||
"switch.thumb.background": "#FFFFFF",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#EBFFC1",
|
||||
"tab.active.foreground": "#3F6212",
|
||||
"tab.background": "#E8E8E8",
|
||||
"tab.active.background": "#F5F5F5",
|
||||
"tab.active.foreground": "#18181B",
|
||||
"tab.foreground": "#646464",
|
||||
"tab_bar.background": "#F0F0F0",
|
||||
"tab_bar.segmented.background": "#F0F0F0",
|
||||
@@ -286,9 +286,9 @@
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#484848",
|
||||
"switch.thumb.background": "#0A0A0A",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#19200A",
|
||||
"tab.active.foreground": "#C6FF4D",
|
||||
"tab.background": "#2E2E2E",
|
||||
"tab.active.background": "#2A2A2A",
|
||||
"tab.active.foreground": "#FAFAFA",
|
||||
"tab.foreground": "#B4B4B4",
|
||||
"tab_bar.background": "#191919",
|
||||
"tab_bar.segmented.background": "#191919",
|
||||
|
||||
@@ -39,12 +39,7 @@ impl Assets {
|
||||
.filter_map(|path| {
|
||||
let data = Self::get(path.as_ref())?;
|
||||
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
||||
let content = match data.data {
|
||||
std::borrow::Cow::Borrowed(bytes) => {
|
||||
std::str::from_utf8(bytes).ok()?.to_owned()
|
||||
}
|
||||
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
|
||||
};
|
||||
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
|
||||
Some((name.to_owned(), content))
|
||||
})
|
||||
.collect()
|
||||
@@ -111,61 +106,3 @@ impl IconNamed for CustomIconName {
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signed_theme_set() -> gpui_component::ThemeSet {
|
||||
let themes = Assets.themes();
|
||||
assert_eq!(themes.len(), 1, "expected exactly one embedded theme file");
|
||||
let (name, content) = &themes[0];
|
||||
assert_eq!(name, "signed.json");
|
||||
serde_json::from_str(content).expect("theme file must be a valid ThemeSet")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_theme_set_parses() {
|
||||
let set = signed_theme_set();
|
||||
let names: Vec<&str> = set.themes.iter().map(|t| t.name.as_ref()).collect();
|
||||
assert_eq!(names, vec!["Signed Light", "Signed Dark"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_theme_palette_applies() {
|
||||
let set = signed_theme_set();
|
||||
let parse = |hex: &str| gpui_component::try_parse_color(hex).unwrap();
|
||||
for config in &set.themes {
|
||||
let mut theme = gpui_component::Theme::default();
|
||||
theme.apply_config(&std::rc::Rc::new(config.clone()));
|
||||
|
||||
assert_eq!(theme.mode, config.mode);
|
||||
// The resolved colors must match the brand palette.
|
||||
assert_eq!(theme.primary, parse("#C6FF4D")); // nostr-lime
|
||||
assert_eq!(theme.success, parse("#2FBF71")); // merge
|
||||
assert_eq!(theme.primary_active, parse("#65A30D")); // lime-600
|
||||
|
||||
if config.mode.is_dark() {
|
||||
// Dark theme chrome is neutral, mirroring the light theme.
|
||||
assert_eq!(theme.background, parse("#0A0A0A")); // neutral-950
|
||||
assert_eq!(theme.border, parse("#27272A")); // neutral-800
|
||||
assert_eq!(theme.green, parse("#22C55E")); // green-500
|
||||
} else {
|
||||
// Light theme chrome is neutral, lime is a brand accent only.
|
||||
assert_eq!(theme.background, parse("#FFFFFF"));
|
||||
assert_eq!(theme.foreground, parse("#18181B"));
|
||||
assert_eq!(theme.border, parse("#E4E4E7"));
|
||||
assert_eq!(theme.green, parse("#16A34A"));
|
||||
}
|
||||
// Active tab, a paler lime on light and a dim moss on dark.
|
||||
// Each is paired with readable contrasting text.
|
||||
if config.mode.is_dark() {
|
||||
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
|
||||
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
|
||||
} else {
|
||||
assert_eq!(theme.tab_active, parse("#EBFFC1")); // pale nostr-lime
|
||||
assert_eq!(theme.tab_active_foreground, parse("#3F6212")); // deep-lime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
settings = { path = "../settings" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -11,22 +11,19 @@ use gpui::{
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||
PanelView, TabGroupRenderer, TilesRenderer,
|
||||
PanelView, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::resize_handle;
|
||||
use gpui_component::scroll::ScrollbarMode;
|
||||
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
|
||||
use gpui_component::{ActiveTheme as _, Side};
|
||||
|
||||
use crate::invalid_panel::InvalidPanel;
|
||||
use crate::panel_handle;
|
||||
use crate::tab_panel::SignedTabGroupSkin;
|
||||
use crate::tiles::SignedTilesSkin;
|
||||
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
||||
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
tiles_scrollbar_mode: Cell<Option<ScrollbarMode>>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
@@ -40,10 +37,6 @@ impl SkinShared {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
||||
self.tiles_scrollbar_mode.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&self.resizing_dock
|
||||
}
|
||||
@@ -73,7 +66,6 @@ impl SignedDockSkin {
|
||||
shared: Rc::new(SkinShared {
|
||||
area: cx.weak_entity(),
|
||||
toggle_button_visible: Cell::new(true),
|
||||
tiles_scrollbar_mode: Cell::new(None),
|
||||
resizing_dock: Cell::new(None),
|
||||
}),
|
||||
})
|
||||
@@ -92,16 +84,6 @@ impl SignedDockSkin {
|
||||
self.shared.toggle_button_visible.set(visible);
|
||||
self.shared.notify(cx);
|
||||
}
|
||||
|
||||
/// When a tiles canvas shows its scrollbar. `None` follows the theme.
|
||||
pub fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
||||
self.shared.tiles_scrollbar_mode()
|
||||
}
|
||||
|
||||
pub fn set_tiles_scrollbar_mode(&self, mode: Option<ScrollbarMode>, cx: &mut App) {
|
||||
self.shared.tiles_scrollbar_mode.set(mode);
|
||||
self.shared.notify(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload a dock's resize handle drags.
|
||||
@@ -154,30 +136,10 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let placement = dock.placement();
|
||||
let open = dock.is_open();
|
||||
|
||||
// A closed left or right dock takes no space.
|
||||
// A closed bottom dock keeps a strip so its tab bar stays clickable.
|
||||
if !open && !placement.is_bottom() {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.map(|this| match placement {
|
||||
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
|
||||
DockPlacement::Bottom => this.w_full().h(dock.size()),
|
||||
// Base never builds a dock for the centre.
|
||||
DockPlacement::Center => this,
|
||||
})
|
||||
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
|
||||
.when(!open && placement.is_bottom(), |this| {
|
||||
this.h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
.child(content)
|
||||
.child(self.render_resize_handle(dock, window, cx))
|
||||
.child(DockResizeTracker {
|
||||
@@ -204,10 +166,6 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||
}
|
||||
|
||||
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
||||
Rc::new(SignedTilesSkin::new(self.shared().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SignedDockSkin {
|
||||
|
||||
@@ -6,7 +6,6 @@ use gpui_base::dock::PanelView;
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
mod tab_panel;
|
||||
mod tiles;
|
||||
mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
|
||||
+306
-175
@@ -5,9 +5,9 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
|
||||
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
|
||||
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
||||
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
||||
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled as _, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
@@ -18,17 +18,24 @@ use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||
use gpui_component::menu::DropdownMenu as _;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
use settings::{SettingsStore, TabBarSettings};
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||
|
||||
/// The drag preview's size, reported to base for the drop placeholder.
|
||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
|
||||
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
|
||||
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
|
||||
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
|
||||
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
|
||||
|
||||
/// Whether a group's prev/next buttons should be drawn.
|
||||
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
|
||||
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
|
||||
}
|
||||
|
||||
/// A panel's title, or its registered name when the panel has no handle.
|
||||
pub(crate) fn panel_title(
|
||||
@@ -43,24 +50,20 @@ pub(crate) fn panel_title(
|
||||
}
|
||||
|
||||
/// The preview that follows the cursor while a panel is dragged.
|
||||
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
||||
struct DragPanelPreview {
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
}
|
||||
|
||||
impl Render for DragPanelPreview {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
h_flex()
|
||||
.id("drag-panel")
|
||||
.cursor_grab()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.w_24()
|
||||
.overflow_hidden()
|
||||
.p_1()
|
||||
.whitespace_nowrap()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tokens.tab_active)
|
||||
.opacity(0.75)
|
||||
@@ -69,9 +72,14 @@ impl Render for DragPanelPreview {
|
||||
}
|
||||
|
||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
let panel = group.active_panel()?;
|
||||
panel_zoom_control(group.active_panel()?, cx)
|
||||
}
|
||||
|
||||
/// The zoom affordance `panel` offers, if any.
|
||||
///
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||
panel
|
||||
.zoomable(cx)
|
||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||
@@ -79,16 +87,17 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
}
|
||||
|
||||
/// The left-most, top-most tab group in a container.
|
||||
///
|
||||
/// A left dock's collapse button lives in this group.
|
||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||
PaneRef::Tiles { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-most, top-most tab group.
|
||||
///
|
||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
@@ -98,7 +107,53 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
gpui::Axis::Horizontal => children.last(),
|
||||
}
|
||||
.and_then(right_top_group),
|
||||
PaneRef::Tiles { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group is this one, if any.
|
||||
///
|
||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||
fn dock_root_group(
|
||||
shared: &SkinShared,
|
||||
group: &TabGroupContext,
|
||||
cx: &App,
|
||||
) -> Option<DockPlacement> {
|
||||
let area = shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
|
||||
[DockPlacement::Bottom, DockPlacement::Right]
|
||||
.into_iter()
|
||||
.find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.root().id() == group.node())
|
||||
})
|
||||
}
|
||||
|
||||
/// Close `panel`, removing its dock instead when base would refuse an empty one.
|
||||
///
|
||||
/// A bottom or right dock's only panel is that case, so the whole dock goes.
|
||||
fn close_panel(
|
||||
shared: &SkinShared,
|
||||
group: &TabGroupContext,
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if !panel.closable(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dock = (group.panels().len() <= 1)
|
||||
.then(|| dock_root_group(shared, group, cx))
|
||||
.flatten();
|
||||
|
||||
match dock {
|
||||
Some(placement) => {
|
||||
_ = shared.area().update(cx, |area, cx| {
|
||||
area.remove_dock(placement, window, cx);
|
||||
});
|
||||
}
|
||||
None => group.close(panel.panel_id(cx), window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,26 +199,14 @@ impl SignedTabGroupSkin {
|
||||
left == group.node() && group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group is this one, if any.
|
||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
[DockPlacement::Bottom, DockPlacement::Right]
|
||||
.into_iter()
|
||||
.find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.root().id() == group.node())
|
||||
})
|
||||
}
|
||||
|
||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||
///
|
||||
/// A locked group never is, a bottom or right dock root always is.
|
||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
}
|
||||
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
|
||||
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
|
||||
return None;
|
||||
}
|
||||
group.drag_panel(ix, cx)
|
||||
@@ -198,7 +241,7 @@ impl SignedTabGroupSkin {
|
||||
DockPlacement::Bottom => area
|
||||
.layout(DockPlacement::Bottom)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Center => None,
|
||||
DockPlacement::Center => return None,
|
||||
};
|
||||
if designated != Some(group.node()) {
|
||||
return None;
|
||||
@@ -233,15 +276,22 @@ impl SignedTabGroupSkin {
|
||||
}
|
||||
|
||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||
/// Always rendered, disabled at the strip ends or when collapsed.
|
||||
fn render_prev_next_tab_buttons(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
_cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
///
|
||||
/// Hidden by default, and only offered for crowded strips where the active
|
||||
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
||||
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let panels_len = group.panels().len();
|
||||
|
||||
let hidden = SettingsStore::try_global(cx)
|
||||
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
|
||||
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
|
||||
|
||||
if !navigation_buttons_visible(hidden, panels_len) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let prev_enabled = !collapsed && active_ix > 0;
|
||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||
|
||||
@@ -273,100 +323,10 @@ impl SignedTabGroupSkin {
|
||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
if group.is_collapsed() {
|
||||
return div();
|
||||
}
|
||||
|
||||
let zoomed = group.is_zoomed();
|
||||
let handle = group.active_panel().and_then(PanelHandle::of);
|
||||
let control = zoom_control(group, cx);
|
||||
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
||||
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
||||
// A bottom or right dock's only panel cannot close through the group.
|
||||
// The close item is offered, the skin removes the whole dock instead.
|
||||
let closable = group.is_closable()
|
||||
|| (self.is_dock_root_group(group, cx).is_some()
|
||||
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
||||
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
||||
let panel = handle.map(|handle| handle.panel());
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.occlude()
|
||||
.when_some(buttons, |this, buttons| {
|
||||
this.children(
|
||||
buttons
|
||||
.into_iter()
|
||||
.map(|button| button.small().ghost().tab_stop(false)),
|
||||
)
|
||||
})
|
||||
.map(|this| {
|
||||
let value = if zoomed {
|
||||
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
|
||||
} else if toolbar_zoom {
|
||||
Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((id, icon, tooltip)) = value {
|
||||
this.child(
|
||||
Button::new(id)
|
||||
.icon(icon)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip_with_action(tooltip, &ToggleZoom, None)
|
||||
.selected(zoomed)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
move |_, window, cx| group.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.dropdown_menu(move |menu, window, cx| {
|
||||
menu.when_some(panel.clone(), |menu, panel| {
|
||||
panel.dropdown_menu(menu, window, cx)
|
||||
})
|
||||
.separator()
|
||||
.menu_with_disabled(
|
||||
if zoomed {
|
||||
t("Dock.Zoom Out")
|
||||
} else {
|
||||
t("Dock.Zoom In")
|
||||
},
|
||||
Box::new(ToggleZoom),
|
||||
!menu_zoom,
|
||||
)
|
||||
.when(closable, |menu| {
|
||||
menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
|
||||
})
|
||||
})
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One tab of the pill strip.
|
||||
/// While collapsed, tabs lose the active style and all interactions.
|
||||
/// The strip is also how a closed bottom dock is opened again.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
@@ -382,25 +342,73 @@ impl SignedTabGroupSkin {
|
||||
let droppable = group.is_droppable();
|
||||
let drag = self.tab_drag(group, ix, cx);
|
||||
let handle = PanelHandle::of(&panel);
|
||||
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
||||
|
||||
let closable = !collapsed
|
||||
&& (group.is_closable()
|
||||
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
||||
|
||||
// Whether a double-click zooms this tab in.
|
||||
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
||||
|
||||
Tab::new(ix)
|
||||
.h_6()
|
||||
.px_3()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.group(TAB_HOVER_GROUP)
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.flex_shrink_0()
|
||||
.h_7()
|
||||
.min_w_40()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().foreground)
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
}),
|
||||
)
|
||||
.when(closable, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
||||
})
|
||||
.when(!active, |this| {
|
||||
this.invisible()
|
||||
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
||||
})
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip(t("Dock.Close"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let shared = self.shared.clone();
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
close_panel(&shared, &group, &panel, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
// Pill style, the selected tab is the filled pill, others show only on hover.
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
@@ -412,15 +420,15 @@ impl SignedTabGroupSkin {
|
||||
if active {
|
||||
this
|
||||
} else {
|
||||
this.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary_hover)
|
||||
this.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tab)
|
||||
}
|
||||
})
|
||||
.selected(active)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let area = self.shared.area().clone();
|
||||
move |_, window, cx| {
|
||||
move |event, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||
@@ -428,7 +436,15 @@ impl SignedTabGroupSkin {
|
||||
_ = area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A double-click zooms the tab's panel
|
||||
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.when(!collapsed, |this| {
|
||||
@@ -531,10 +547,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
// Base refuses an empty dock, so closing its only panel removes the dock.
|
||||
let dock_to_remove = (group.panels().len() <= 1)
|
||||
.then(|| self.is_dock_root_group(group, cx))
|
||||
.flatten();
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||
@@ -543,14 +555,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
// A collapsed group has no content, so these actions are not registered.
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
// A panel with no zoom control is not zoomed in by the keybinding.
|
||||
// Zooming out is never refused.
|
||||
// Otherwise a zoomed panel that lost its control would strand the user.
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -564,18 +572,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
if !panel.closable(cx) {
|
||||
return;
|
||||
}
|
||||
let panel = panel.panel_id(cx);
|
||||
match dock_to_remove {
|
||||
Some(placement) => {
|
||||
_ = shared.area().update(cx, |area, cx| {
|
||||
area.remove_dock(placement, window, cx);
|
||||
});
|
||||
}
|
||||
None => group.close(panel, window, cx),
|
||||
}
|
||||
close_panel(&shared, &group, panel, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -613,17 +610,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// On macOS the traffic lights overlay the window's top-left corner.
|
||||
// Only the tab bar that sits under them reserves the space.
|
||||
// That is the center's top-left group when the left dock is closed or absent.
|
||||
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||
// tab bar under them reserves the space. That is the center's top-left group
|
||||
// when the left dock is closed or absent, and the zoomed group otherwise:
|
||||
// a zoom fills the whole area, hiding the docks the frame would draw.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
match area.zoomed_group() {
|
||||
Some(zoomed) => zoomed == group.node(),
|
||||
None => {
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view.
|
||||
@@ -709,7 +712,16 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
.child(
|
||||
Tabs::new("tab-bar")
|
||||
.px(px(-1.))
|
||||
.h(TAB_BAR_HEIGHT)
|
||||
// A collapsed group stands in for a closed bottom dock,
|
||||
// which base gives only its reserved strip. The bar keeps
|
||||
// its own height there rather than the title-bar height,
|
||||
// so it fits instead of being clipped away.
|
||||
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
||||
// Only a collapsed (bottom) dock tags its bar, so the
|
||||
// selector is unique when a test measures it.
|
||||
.when(collapsed, |this| {
|
||||
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
@@ -763,7 +775,6 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
.and_then(PanelHandle::of)
|
||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||
)
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.children(right_dock_button),
|
||||
)
|
||||
}),
|
||||
@@ -833,3 +844,123 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
Render, TestAppContext, Window, px,
|
||||
};
|
||||
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
|
||||
|
||||
use super::{COLLAPSED_TAB_BAR_SELECTOR, navigation_buttons_visible};
|
||||
use crate::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
|
||||
struct Probe {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for Probe {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Probe {
|
||||
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Probe {}
|
||||
|
||||
impl Focusable for Probe {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Base reserves only `gpui_base::dock::CLOSED_BOTTOM_STRIP` for a closed
|
||||
/// bottom dock, so the tab bar drawn in place of one has to shrink to that
|
||||
/// height; at the full title-bar height its bottom was clipped away.
|
||||
#[gpui::test]
|
||||
fn a_collapsed_bottom_dock_bar_fits_the_reserved_strip(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
});
|
||||
let (area, cx) = cx.add_window_view(|window, cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
DockArea::new("test", None, window, cx).with_renderer(skin)
|
||||
});
|
||||
|
||||
cx.update(|window, cx| {
|
||||
let center = cx.new(Probe::new);
|
||||
let bottom = cx.new(Probe::new);
|
||||
area.update(cx, |area, cx| {
|
||||
area.set_center(
|
||||
DockLayout::tabs().panel_view(panel_handle(center), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
area.set_dock(
|
||||
DockPlacement::Bottom,
|
||||
DockLayout::tabs().panel_view(panel_handle(bottom), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// Open, nothing is collapsed, so no bar carries the selector.
|
||||
assert!(cx.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR).is_none());
|
||||
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
let bounds = cx
|
||||
.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR)
|
||||
.expect("a collapsed bottom dock draws its stand-in tab bar");
|
||||
// `gpui_base::dock::CLOSED_BOTTOM_STRIP`, which the engine does not
|
||||
// re-export. The bar used to be `TAB_BAR_HEIGHT` here and its bottom was
|
||||
// clipped away; it now has to fit inside what base reserves.
|
||||
let reserved_strip = px(29.);
|
||||
assert!(
|
||||
bounds.size.height <= reserved_strip,
|
||||
"the collapsed bar is {}px tall, more than the {}px strip base reserves",
|
||||
bounds.size.height,
|
||||
reserved_strip,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_buttons_follow_the_setting_and_tab_count() {
|
||||
// Hidden by default, no matter how many tabs there are.
|
||||
assert!(!navigation_buttons_visible(true, 1));
|
||||
assert!(!navigation_buttons_visible(true, 20));
|
||||
|
||||
// When not hidden they still only appear for a crowded strip.
|
||||
assert!(!navigation_buttons_visible(false, 10));
|
||||
assert!(navigation_buttons_visible(false, 11));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Div, DragMoveEvent, Empty, InteractiveElement as _,
|
||||
IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
||||
Size, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::PanelHandle;
|
||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::t;
|
||||
use crate::tab_panel::panel_title;
|
||||
|
||||
/// How far a resize handle sticks out past the tile's edge.
|
||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
||||
|
||||
/// The payload a tile drag carries, so one canvas ignores another's drags.
|
||||
#[derive(Clone)]
|
||||
struct DragMoving(NodeId);
|
||||
|
||||
impl Render for DragMoving {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload a tile resize carries, for the same reason.
|
||||
#[derive(Clone)]
|
||||
struct DragResizing(NodeId);
|
||||
|
||||
impl Render for DragResizing {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// One tiles canvas's appearance.
|
||||
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
|
||||
pub(crate) struct SignedTilesSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl SignedTilesSkin {
|
||||
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
|
||||
Self {
|
||||
shared,
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge or corner handle.
|
||||
fn resize_handle(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
id: &'static str,
|
||||
side: ResizeSide,
|
||||
build: impl FnOnce(Stateful<Div>) -> Stateful<Div>,
|
||||
) -> Stateful<Div> {
|
||||
let node = tile.node();
|
||||
|
||||
build(div().id(id).absolute())
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
tile.begin_resize(side, event.position, window, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.on_drag(DragResizing(node), |drag, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new(|_| drag.clone())
|
||||
})
|
||||
.on_drag_move({
|
||||
let tile = tile.clone();
|
||||
move |event: &DragMoveEvent<DragResizing>, window, cx| {
|
||||
if event.drag(cx).0 != node {
|
||||
return;
|
||||
}
|
||||
tile.resize_to(event.event.position, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
|
||||
/// They use click handlers, the zoom and close actions target a focused tab group.
|
||||
fn render_tile_controls(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let handle = PanelHandle::of(tile.panel());
|
||||
let control = handle.and_then(|handle| handle.zoom_control(cx));
|
||||
let zoomed = tile.is_zoomed();
|
||||
let toolbar_zoom =
|
||||
tile.is_zoomable() && control.is_some_and(|control| control.toolbar_visible());
|
||||
let menu_zoom = tile.is_zoomable() && control.is_some_and(|control| control.menu_visible());
|
||||
let closable = tile.is_closable();
|
||||
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
||||
let panel = handle.map(|handle| handle.panel());
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_shrink_0()
|
||||
.occlude()
|
||||
.when_some(buttons, |this, buttons| {
|
||||
this.children(
|
||||
buttons
|
||||
.into_iter()
|
||||
.map(|button| button.xsmall().ghost().tab_stop(false)),
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
match (zoomed, toolbar_zoom) {
|
||||
(true, _) => Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))),
|
||||
(false, true) => Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))),
|
||||
(false, false) => None,
|
||||
},
|
||||
|this, (id, icon, tooltip)| {
|
||||
this.child(
|
||||
Button::new(id)
|
||||
.icon(icon)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip(tooltip)
|
||||
.selected(zoomed)
|
||||
.on_click({
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.dropdown_menu({
|
||||
let tile = tile.clone();
|
||||
move |menu, window, cx| {
|
||||
menu.when_some(panel.clone(), |menu, panel| {
|
||||
panel.dropdown_menu(menu, window, cx)
|
||||
})
|
||||
.separator()
|
||||
.item(
|
||||
PopupMenuItem::new(match zoomed {
|
||||
true => t("Dock.Zoom Out"),
|
||||
false => t("Dock.Zoom In"),
|
||||
})
|
||||
.disabled(!menu_zoom && !zoomed)
|
||||
.on_click({
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
.when(closable, |menu| {
|
||||
menu.separator()
|
||||
.item(PopupMenuItem::new(t("Dock.Close")).on_click({
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.close(window, cx)
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
.anchor(gpui::Anchor::TopRight),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TilesRenderer for SignedTilesSkin {
|
||||
fn frame(&self, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("tiles")
|
||||
.relative()
|
||||
.size_full()
|
||||
.bg(cx.theme().tokens.tiles)
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.overflow_scroll()
|
||||
}
|
||||
|
||||
fn tile_frame(&self, tile: &TileContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id(("tile", tile.panel_id().as_u64()))
|
||||
.occlude()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().tile_radius)
|
||||
// Room for the title bar, which overlays the top padding.
|
||||
// Base draws the panel as a plain child, this keeps them apart.
|
||||
.pt(DRAG_BAR_HEIGHT)
|
||||
// Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
|
||||
.when(tile.is_zoomed(), |this| this.size_full())
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.bring_to_front(window, cx)
|
||||
})
|
||||
// A gesture can end anywhere, so both mouse-up hooks run.
|
||||
// Each is a no-op unless this tile is the one that moved.
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| {
|
||||
tile.end_move(window, cx);
|
||||
tile.end_resize(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_up_out(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| {
|
||||
tile.end_move(window, cx);
|
||||
tile.end_resize(window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let node = tile.node();
|
||||
let handle = PanelHandle::of(tile.panel());
|
||||
let title_style = handle.and_then(|handle| handle.title_style(cx));
|
||||
|
||||
h_flex()
|
||||
.id("drag-bar")
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h(DRAG_BAR_HEIGHT)
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.pl_3()
|
||||
.pr_2()
|
||||
.when_some(title_style, |this, style| {
|
||||
this.bg(style.background).text_color(style.foreground)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_16()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(panel_title(tile.panel(), window, cx)),
|
||||
)
|
||||
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
|
||||
.child(self.render_tile_controls(tile, window, cx))
|
||||
// A zoomed tile is not at its stored bounds, so moving it would mean nothing.
|
||||
.when(!tile.is_zoomed(), |this| {
|
||||
this.cursor_grab()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
tile.begin_move(event.position, window, cx);
|
||||
}
|
||||
})
|
||||
.on_drag(DragMoving(node), |drag, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new(|_| drag.clone())
|
||||
})
|
||||
.on_drag_move({
|
||||
let tile = tile.clone();
|
||||
move |event: &DragMoveEvent<DragMoving>, window, cx| {
|
||||
if event.drag(cx).0 != node {
|
||||
return;
|
||||
}
|
||||
tile.move_to(event.event.position, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_resize_handles(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let bounds = tile.bounds();
|
||||
|
||||
// A passive full-tile box, so handles sit against the tile, not its flow neighbours.
|
||||
// It registers no interaction, so it does not shadow the panel underneath.
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.child(
|
||||
self.resize_handle(tile, "left-resize-handle", ResizeSide::Left, |this| {
|
||||
this.cursor_ew_resize()
|
||||
.top_0()
|
||||
.left(HANDLE_OFFSET)
|
||||
.w(HANDLE_SIZE)
|
||||
.h(bounds.size.height)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
self.resize_handle(tile, "right-resize-handle", ResizeSide::Right, |this| {
|
||||
this.cursor_ew_resize()
|
||||
.top_0()
|
||||
.right(HANDLE_OFFSET)
|
||||
.w(HANDLE_SIZE)
|
||||
.h(bounds.size.height)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
self.resize_handle(tile, "top-resize-handle", ResizeSide::Top, |this| {
|
||||
this.cursor_ns_resize()
|
||||
.left_0()
|
||||
.top(HANDLE_OFFSET)
|
||||
.w(bounds.size.width)
|
||||
.h(HANDLE_SIZE)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
self.resize_handle(tile, "bottom-resize-handle", ResizeSide::Bottom, |this| {
|
||||
this.cursor_ns_resize()
|
||||
.left_0()
|
||||
.bottom(HANDLE_OFFSET)
|
||||
.w(bounds.size.width)
|
||||
.h(HANDLE_SIZE)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::ResizeCorner)
|
||||
.size_3()
|
||||
.absolute()
|
||||
.right(px(1.))
|
||||
.bottom(px(1.))
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.5)),
|
||||
)
|
||||
.child(self.resize_handle(
|
||||
tile,
|
||||
"corner-resize-handle",
|
||||
ResizeSide::BottomRight,
|
||||
|this| {
|
||||
this.cursor_nwse_resize()
|
||||
.right(HANDLE_OFFSET)
|
||||
.bottom(HANDLE_OFFSET)
|
||||
.size_3()
|
||||
},
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
|
||||
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
h_flex()
|
||||
.id(("tile-panel", tile.panel_id().as_u64()))
|
||||
.overflow_hidden()
|
||||
.size_full()
|
||||
}
|
||||
|
||||
/// The canvas scrollbar, as an overlay.
|
||||
/// Placed inside the frame it would end up underneath every tile.
|
||||
fn render_overlay(
|
||||
&self,
|
||||
content: Size<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
Some(
|
||||
Scrollbar::new(&self.scroll_handle)
|
||||
.scroll_size(content)
|
||||
.when_some(self.shared.tiles_scrollbar_mode(), |this, mode| {
|
||||
this.mode(mode)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn grid_size(&self, cx: &App) -> Pixels {
|
||||
cx.theme().tile_grid_size
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
|
||||
|
||||
use crate::TAB_BAR_HEIGHT;
|
||||
|
||||
/// The standard width of a window control button.
|
||||
const CONTROL_WIDTH: f32 = 34.;
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
|
||||
@@ -23,7 +23,6 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
/// On Windows, this is `%APPDATA%\Signed`.
|
||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Returns the current user's home directory.
|
||||
pub fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("failed to determine home directory")
|
||||
}
|
||||
@@ -42,7 +41,6 @@ pub fn documents_dir() -> PathBuf {
|
||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
@@ -62,7 +60,6 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the data directory.
|
||||
pub fn data_dir() -> &'static PathBuf {
|
||||
CURRENT_DATA_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -93,12 +90,15 @@ pub fn nostr_dir() -> &'static PathBuf {
|
||||
}
|
||||
|
||||
/// Returns the path to the local git clone cache, the grasp mirrors.
|
||||
///
|
||||
/// The mirrors are disposable and re-cloned from their grasp server on
|
||||
/// demand, so the cache lives in the OS temp directory for the system to
|
||||
/// reclaim.
|
||||
pub fn repos_dir() -> &'static PathBuf {
|
||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||
REPOS_DIR.get_or_init(|| std::env::temp_dir().join(APP_NAME_LOWERCASE).join("repos"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `settings.json` file.
|
||||
pub fn settings_file() -> &'static PathBuf {
|
||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||
|
||||
@@ -2,29 +2,33 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The default grasp servers,
|
||||
/// offered while the user has not published a grasp list.
|
||||
/// The default grasp servers, offered while the user has not published a grasp list.
|
||||
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||
"wss://relay.ngit.dev",
|
||||
"wss://gitnostr.com",
|
||||
"wss://git.shakespeare.diy",
|
||||
];
|
||||
|
||||
/// How the application picks its appearance.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the system appearance, light or dark, at runtime.
|
||||
#[default]
|
||||
System,
|
||||
/// Always use the light theme.
|
||||
Light,
|
||||
/// Always use the dark theme.
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Theme configuration,
|
||||
/// fields mirror the gpui-component `Theme` surface customized at startup.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventFetchingStrategy {
|
||||
/// Only the relays declared in the repository announcement.
|
||||
Curated,
|
||||
/// Repository relays plus every maintainer's NIP-65 relays.
|
||||
#[default]
|
||||
Uncensored,
|
||||
}
|
||||
|
||||
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ThemeSettings {
|
||||
@@ -42,7 +46,6 @@ pub struct ThemeSettings {
|
||||
pub radius_lg: f32,
|
||||
/// Whether focused controls draw a ring outside their border.
|
||||
pub focus_ring: bool,
|
||||
/// Whether to render shadows.
|
||||
pub shadow: bool,
|
||||
}
|
||||
|
||||
@@ -61,7 +64,22 @@ impl Default for ThemeSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default grasp server settings.
|
||||
/// Tab bar chrome.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TabBarSettings {
|
||||
/// Hide the previous and next tab buttons.
|
||||
pub hide_navigation_buttons: bool,
|
||||
}
|
||||
|
||||
impl Default for TabBarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hide_navigation_buttons: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GraspServersSettings {
|
||||
@@ -80,7 +98,6 @@ impl Default for GraspServersSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Local repository scanning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LocalReposSettings {
|
||||
@@ -108,7 +125,6 @@ impl Default for LocalReposSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutRecord {
|
||||
/// Local folder of the checkout.
|
||||
pub path: PathBuf,
|
||||
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
||||
pub addr: String,
|
||||
@@ -116,16 +132,13 @@ pub struct CheckoutRecord {
|
||||
pub last_used: u64,
|
||||
}
|
||||
|
||||
/// Remembered local checkouts, see [`CheckoutRecord`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutsSettings {
|
||||
/// The remembered records.
|
||||
/// The latest use of a path and repo pair replaces the older record.
|
||||
pub records: Vec<CheckoutRecord>,
|
||||
}
|
||||
|
||||
/// The create-repository dialog.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CreateRepositorySettings {
|
||||
@@ -137,17 +150,13 @@ pub struct CreateRepositorySettings {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// How the application picks its appearance.
|
||||
pub appearance: AppearanceMode,
|
||||
/// Theme configuration.
|
||||
pub event_fetching: EventFetchingStrategy,
|
||||
pub theme: ThemeSettings,
|
||||
/// Default grasp servers.
|
||||
pub tab_bar: TabBarSettings,
|
||||
pub grasp_servers: GraspServersSettings,
|
||||
/// Local repository scanning.
|
||||
pub local_repos: LocalReposSettings,
|
||||
/// Remembered local checkouts.
|
||||
pub checkouts: CheckoutsSettings,
|
||||
/// The create-repository dialog.
|
||||
pub create_repository: CreateRepositorySettings,
|
||||
}
|
||||
|
||||
@@ -155,34 +164,11 @@ pub struct Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_app_conventions() {
|
||||
let settings = Settings::default();
|
||||
assert_eq!(settings.appearance, AppearanceMode::System);
|
||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||
assert_eq!(settings.theme.dark_theme, "Signed Dark");
|
||||
assert_eq!(settings.theme.font_size, 16.0);
|
||||
assert_eq!(settings.theme.mono_font_size, 13.0);
|
||||
assert_eq!(settings.theme.radius, 2.0);
|
||||
assert_eq!(settings.theme.radius_lg, 6.0);
|
||||
assert!(!settings.theme.focus_ring);
|
||||
assert!(!settings.theme.shadow);
|
||||
assert_eq!(
|
||||
settings.grasp_servers.default_servers,
|
||||
DEFAULT_GRASP_SERVERS.map(String::from).to_vec()
|
||||
);
|
||||
assert_eq!(settings.local_repos.scan_paths.len(), 2);
|
||||
assert_eq!(
|
||||
settings.local_repos.scan_paths,
|
||||
vec![paths::desktop_dir(), paths::documents_dir()]
|
||||
);
|
||||
assert_eq!(settings.create_repository.default_folder, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip_preserves_everything() {
|
||||
let settings = Settings {
|
||||
appearance: AppearanceMode::Dark,
|
||||
event_fetching: EventFetchingStrategy::Curated,
|
||||
theme: ThemeSettings {
|
||||
radius: 8.0,
|
||||
..Default::default()
|
||||
@@ -198,18 +184,14 @@ mod tests {
|
||||
assert_eq!(parsed, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_fall_back_to_defaults() {
|
||||
let settings: Settings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(settings, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_merges_with_defaults() {
|
||||
let settings: Settings =
|
||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||
assert_eq!(settings.theme.radius, 4.0);
|
||||
// Unset fields fall back to their defaults, including the fetching strategy.
|
||||
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
|
||||
// The rest of the theme and the other groups keep their defaults.
|
||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||
@@ -217,18 +199,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_serializes_to_snake_case_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::System).unwrap(),
|
||||
"\"system\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::Light).unwrap(),
|
||||
"\"light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::Dark).unwrap(),
|
||||
"\"dark\""
|
||||
);
|
||||
fn event_fetching_uses_snake_case() {
|
||||
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
|
||||
assert_eq!(json, r#""curated""#);
|
||||
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
|
||||
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ impl SettingsStore {
|
||||
cx.global::<GlobalSettingsStore>().0.clone()
|
||||
}
|
||||
|
||||
/// Install the store as a global.
|
||||
/// Retrieve the global settings store if one has been installed.
|
||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalSettingsStore>()
|
||||
.map(|store| store.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalSettingsStore(entity));
|
||||
}
|
||||
@@ -103,8 +108,6 @@ impl SettingsStore {
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use gpui::{AppContext, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
|
||||
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -133,16 +136,6 @@ mod tests {
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_loads_defaults() {
|
||||
let path = temp_settings_path();
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
|
||||
let settings = SettingsStore::load(&path);
|
||||
assert_eq!(settings, Settings::default());
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let path = temp_settings_path();
|
||||
@@ -160,21 +153,4 @@ mod tests {
|
||||
assert_eq!(SettingsStore::load(&path), expected);
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn edit_mutates_and_persists(cx: &mut TestAppContext) {
|
||||
let path = temp_settings_path();
|
||||
cleanup(&path);
|
||||
|
||||
let store = cx.update(|cx| cx.new(|cx| SettingsStore::new(path.clone(), cx)));
|
||||
cx.read(|cx| assert_eq!(store.read(cx).settings(), &Settings::default()));
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius = 12.0, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| assert_eq!(store.read(cx).settings().theme.radius, 12.0));
|
||||
assert_eq!(SettingsStore::load(&path).theme.radius, 12.0);
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,3 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -6,12 +6,10 @@ use nostr::prelude::*;
|
||||
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
||||
pub type RepoAddr = Coordinate;
|
||||
|
||||
/// Build the address of a NIP-34 repository announcement.
|
||||
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
||||
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
||||
}
|
||||
|
||||
/// Derive a repository identifier from a display name
|
||||
pub fn identifier_from_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
@@ -23,17 +21,3 @@ pub fn identifier_from_name(name: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identifier_from_name_slugs_like_gitworkshop() {
|
||||
assert_eq!(identifier_from_name("My Repo"), "My-Repo");
|
||||
assert_eq!(identifier_from_name("my-repo"), "my-repo");
|
||||
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
|
||||
assert_eq!(identifier_from_name("a/b"), "a/b");
|
||||
assert_eq!(identifier_from_name("Café"), "Caf-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
||||
///
|
||||
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
||||
/// not part of the NIP-34 draft, read support for interop.
|
||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||
|
||||
/// Whether a kind-1985 label event is a valid annotation of `root`.
|
||||
///
|
||||
/// The event references the root with a lowercase `e` tag,
|
||||
/// its author must be the root author or a maintainer.
|
||||
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||
if event.kind != Kind::Label {
|
||||
return false;
|
||||
}
|
||||
if event.pubkey != root.pubkey && !maintainers.contains(&event.pubkey) {
|
||||
return false;
|
||||
}
|
||||
let root_id = root.id.to_hex();
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||
}
|
||||
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||
/// it must also carry at least one `["l", "<value>", "#t"]` label.
|
||||
fn has_hashtag_labels(event: &Event) -> bool {
|
||||
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective hashtag labels of `root`,
|
||||
/// the `t` tags on the event itself, self-reported by its author,
|
||||
/// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
|
||||
///
|
||||
/// Labels are additive, so all valid label events contribute,
|
||||
/// there is no latest-wins semantics.
|
||||
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||
let mut labels: Vec<String> = root
|
||||
.tags
|
||||
.hashtags()
|
||||
.map(|hashtag| hashtag.to_string())
|
||||
.collect();
|
||||
|
||||
for event in label_events {
|
||||
if !label_targets_root(event, root, maintainers) || !has_hashtag_labels(event) {
|
||||
continue;
|
||||
}
|
||||
for tag in event.tags.iter() {
|
||||
let slice = tag.as_slice();
|
||||
if slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty() {
|
||||
let label = &slice[1];
|
||||
if !labels.contains(label) {
|
||||
labels.push(label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
/// Subject or title override of `root` from authorized kind-1985 label events,
|
||||
/// only label events in the `#subject` namespace count.
|
||||
///
|
||||
/// Returns `None` when no valid override exists.
|
||||
pub fn subject_override(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> Option<String> {
|
||||
label_events
|
||||
.iter()
|
||||
.filter(|event| label_targets_root(event, root, maintainers))
|
||||
.filter(|event| {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.as_slice() == ["L", "#subject"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
slice.len() >= 3
|
||||
&& slice[0] == "l"
|
||||
&& slice[2] == "#subject"
|
||||
&& !slice[1].is_empty()
|
||||
})
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||
})
|
||||
.and_then(|event| {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let slice = tag.as_slice();
|
||||
(slice.len() >= 3
|
||||
&& slice[0] == "l"
|
||||
&& slice[2] == "#subject"
|
||||
&& !slice[1].is_empty())
|
||||
.then(|| slice[1].clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Effective hashtag labels and subject override of `root` in one pass,
|
||||
/// mirrors ngit's `get_labels_and_subject`.
|
||||
pub fn labels_and_subject(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
(
|
||||
labels(root, label_events, maintainers),
|
||||
subject_override(root, label_events, maintainers),
|
||||
)
|
||||
}
|
||||
|
||||
/// Effective cover note of `root`.
|
||||
///
|
||||
/// Returns `None` when no valid cover note exists.
|
||||
pub fn cover_note<'a>(
|
||||
root: &Event,
|
||||
cover_notes: &'a [Event],
|
||||
maintainers: &[PublicKey],
|
||||
) -> Option<&'a Event> {
|
||||
let root_id = root.id.to_hex();
|
||||
|
||||
cover_notes
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& (event.pubkey == root.pubkey || maintainers.contains(&event.pubkey))
|
||||
&& event.tags.iter().any(|tag| {
|
||||
tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)
|
||||
})
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys_from_hex(hex: &str) -> Keys {
|
||||
Keys::new(SecretKey::from_hex(hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn root_event() -> Event {
|
||||
signed(
|
||||
&keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::hashtag("bug")],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_take_inline_hashtags_and_external_label_events() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let labels_event = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "help-wanted", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let labels = labels(&root, &[labels_event], &[maintainer.public_key()]);
|
||||
|
||||
assert_eq!(labels, vec!["bug", "help-wanted"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_ignore_unauthorized_and_misnamed_events() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let stranger =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||
|
||||
// A stranger's label event is not authorized.
|
||||
let stranger_labels = signed(
|
||||
&stranger,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
// A valid author referencing a different event.
|
||||
let other_labels = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
Tag::parse([
|
||||
"e",
|
||||
"2222222222222222222222222222222222222222222222222222222222222222",
|
||||
])
|
||||
.expect("valid e tag"),
|
||||
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
// A valid author without the namespace declaration.
|
||||
let missing_namespace = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
labels(
|
||||
&root,
|
||||
&[stranger_labels, other_labels, missing_namespace],
|
||||
&[maintainer.public_key()]
|
||||
),
|
||||
vec!["bug"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_override_latest_authorized_event_wins() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let older = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "Old title", "#subject"]).expect("valid l tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let newer = signed(
|
||||
&maintainer,
|
||||
Kind::Label,
|
||||
vec![
|
||||
e_tag(&root),
|
||||
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||
Tag::parse(["l", "New title", "#subject"]).expect("valid l tag"),
|
||||
],
|
||||
300,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
subject_override(&root, &[newer, older], &[maintainer.public_key()]),
|
||||
Some("New title".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_note_latest_authorized_event_wins() {
|
||||
let root = root_event();
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
let stranger =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||
let older = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 200);
|
||||
let newer = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 300);
|
||||
let unauthorized = signed(&stranger, COVER_NOTE_KIND, vec![e_tag(&root)], 400);
|
||||
|
||||
let newer_id = newer.id;
|
||||
let events = [older, unauthorized, newer];
|
||||
let maintainers = [maintainer.public_key()];
|
||||
let note = cover_note(&root, &events, &maintainers);
|
||||
assert_eq!(note.map(|event| event.id), Some(newer_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_note_none_without_valid_events() {
|
||||
let root = root_event();
|
||||
|
||||
assert_eq!(cover_note(&root, &[], &[]), None);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Target of a `nostr://` clone URL, as defined by NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CloneTarget {
|
||||
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
/// `npub1...` or a NIP-05 identifier.
|
||||
user: String,
|
||||
relay_hint: Option<RelayUrl>,
|
||||
/// `d` tag identifier of the repository.
|
||||
identifier: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a `nostr://` clone URL. Returns `None` for other URL schemes.
|
||||
pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
let mut parts = rest.split('/');
|
||||
|
||||
let first = parts.next()?;
|
||||
let second = parts.next()?;
|
||||
let third = parts.next();
|
||||
|
||||
if first.starts_with("naddr1") {
|
||||
let coordinate = Nip19Coordinate::from_bech32(first).ok()?;
|
||||
return Some(CloneTarget::Addr(coordinate.coordinate));
|
||||
}
|
||||
|
||||
let (relay_hint, identifier) = match third {
|
||||
Some(id) => (
|
||||
RelayUrl::parse(&percent_decode(second)).ok(),
|
||||
percent_decode(id),
|
||||
),
|
||||
None => (None, percent_decode(second)),
|
||||
};
|
||||
|
||||
Some(CloneTarget::UserRepo {
|
||||
user: first.to_owned(),
|
||||
relay_hint,
|
||||
identifier,
|
||||
})
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = &input[i + 1..i + 3];
|
||||
if let Ok(v) = u8::from_str_radix(hex, 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_user_repo_without_relay() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(),
|
||||
relay_hint: None,
|
||||
identifier: "ngit".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_repo_with_relay_hint() {
|
||||
let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("relay.ngit.dev").ok(),
|
||||
identifier: "ngit".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_parts() {
|
||||
let target = parse_clone_url(
|
||||
"nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target,
|
||||
CloneTarget::UserRepo {
|
||||
user: "danconwaydev.com".to_owned(),
|
||||
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
|
||||
identifier: "my-local-only-repo".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Kinds that make up the activity of a repository.
|
||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
@@ -18,19 +18,18 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
];
|
||||
|
||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequestUpdate,
|
||||
COVER_NOTE_KIND,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Git root kinds that make a comment or cover note count as git activity.
|
||||
/// Git root kinds that make a comment count as git activity.
|
||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPatch,
|
||||
@@ -38,6 +37,15 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitRepoAnnouncement,
|
||||
];
|
||||
|
||||
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||
kind == Kind::GitRepoAnnouncement
|
||||
|| kind == Kind::RepoState
|
||||
|| kind == Kind::EventDeletion
|
||||
|| kind == Kind::RequestToVanish
|
||||
|| ACTIVITY_KINDS.contains(&kind)
|
||||
}
|
||||
|
||||
/// Value of the first tag named `name` on `event`.
|
||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||
event
|
||||
@@ -91,18 +99,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()
|
||||
@@ -152,13 +148,7 @@ pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||
/// through [`is_git_activity`] before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(
|
||||
ACTIVITY_KINDS
|
||||
.into_iter()
|
||||
.chain(std::iter::once(COVER_NOTE_KIND)),
|
||||
)
|
||||
.author(me)
|
||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||
}
|
||||
|
||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||
@@ -167,12 +157,6 @@ fn is_git_comment(event: &Event) -> bool {
|
||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
||||
fn is_git_cover_note(event: &Event) -> bool {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a status event references a git root, checked via its `k` tag.
|
||||
fn is_git_status(event: &Event) -> bool {
|
||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
@@ -187,7 +171,7 @@ pub fn is_git_activity(event: &Event) -> bool {
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => is_git_status(event),
|
||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +180,11 @@ pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// All repository state events, carrying each repository's refs and last push time.
|
||||
pub fn all_states() -> Filter {
|
||||
Filter::new().kind(Kind::RepoState)
|
||||
}
|
||||
|
||||
/// How far back deletion requests are fetched and stored.
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
@@ -253,13 +242,6 @@ mod tests {
|
||||
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_git_kinds_are_activity() {
|
||||
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
|
||||
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_activity_depends_on_the_uppercase_k_tag() {
|
||||
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
|
||||
@@ -281,17 +263,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
||||
fn status_activity_depends_on_the_lowercase_k_tag() {
|
||||
let status = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||
);
|
||||
let cover = signed(
|
||||
&keys(1),
|
||||
COVER_NOTE_KIND,
|
||||
vec![kind_tag("k", Kind::GitPatch)],
|
||||
);
|
||||
let unrelated = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
@@ -299,7 +276,6 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(is_git_activity(&status));
|
||||
assert!(is_git_activity(&cover));
|
||||
assert!(!is_git_activity(&unrelated));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
@@ -307,18 +283,4 @@ mod tests {
|
||||
Vec::new()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_kinds_are_not_activity() {
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::TextNote,
|
||||
Vec::new()
|
||||
)));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitPullRequestUpdate,
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
+149
-176
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||
use crate::{RepoAddr, activity_subject};
|
||||
|
||||
/// Window before `now` that an advanced cutoff retreats to.
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
@@ -12,38 +12,91 @@ const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
/// Window before `now` that a mark-all cutoff retreats to.
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
/// A thread of notification events sharing one root
|
||||
/// A thread of notification and own-activity events sharing one root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
/// The root issue, patch or pull request the notifications belong to.
|
||||
/// The root issue, patch or pull request the events belong to.
|
||||
pub root: EventId,
|
||||
/// Kind of the root event, when it is known locally.
|
||||
pub root_kind: Option<Kind>,
|
||||
/// The root event itself, when it is known locally.
|
||||
pub root_event: Option<Event>,
|
||||
/// Repository the root belongs to, from the root's `a` tag.
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Events in the group, newest first.
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
/// Whether every event in the group is archived.
|
||||
/// Whether every notification event in the thread is archived.
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Timestamp of the newest event in the group.
|
||||
/// Title of the thread, read from its root issue/patch/PR when known.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(activity_subject)
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
/// Timestamp of the newest event in the thread.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.events
|
||||
.first()
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(self.own_events.first())
|
||||
.chain(self.events.first())
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the group has an unread event still visible in the inbox.
|
||||
/// Up to `limit` events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
if let Some(root) = &self.root_event {
|
||||
seen.insert(root.id);
|
||||
events.push(root.clone());
|
||||
}
|
||||
|
||||
let mut rest: Vec<Event> = self
|
||||
.own_events
|
||||
.iter()
|
||||
.chain(self.events.iter())
|
||||
.filter(|event| seen.insert(event.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
rest.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
rest.truncate(limit.saturating_sub(events.len()));
|
||||
events.extend(rest);
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
events
|
||||
}
|
||||
|
||||
/// Whether the thread has an unread event still visible in the inbox.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from `state`.
|
||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||
self.unread_ids = self
|
||||
.events
|
||||
@@ -53,7 +106,9 @@ impl InboxItem {
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
|
||||
self.archived = self.events.iter().all(|event| state.is_archived(event));
|
||||
// A thread without notification events is never archived.
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +121,11 @@ impl InboxItem {
|
||||
/// - patch (1617): its `e` parent patch, else itself
|
||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||
/// - PR update (1619): uppercase `E`
|
||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||
/// - statuses (1630-1633): NIP-10 root `e`
|
||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
if event.kind == COVER_NOTE_KIND {
|
||||
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
||||
}
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match first_e_id(event) {
|
||||
@@ -97,10 +149,17 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Group notification events by root, newest activity first.
|
||||
pub fn group<E, L>(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec<InboxItem>
|
||||
/// Group notification events and the user's own events into one item per thread.
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
@@ -114,24 +173,34 @@ where
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut items: Vec<InboxItem> = groups
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = notification_root(&event, lookup).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|(root, mut events)| {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next()),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
@@ -149,6 +218,15 @@ where
|
||||
items
|
||||
}
|
||||
|
||||
/// Sort thread events newest first, ties broken by id.
|
||||
fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Read and archive state of the inbox, a high-water-mark model.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
@@ -380,10 +458,6 @@ mod tests {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
|
||||
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
|
||||
}
|
||||
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
@@ -392,16 +466,13 @@ mod tests {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_and_pull_request_are_their_own_root() {
|
||||
let events = [
|
||||
issue(&keys(1), 100),
|
||||
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
|
||||
];
|
||||
let lookup = lookup(&events);
|
||||
for event in &events {
|
||||
assert_eq!(notification_root(event, &lookup), Some(event.id));
|
||||
}
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -423,17 +494,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_without_root_pointer_has_no_root() {
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![e_tag(&issue(&keys(1), 100))],
|
||||
200,
|
||||
);
|
||||
assert_eq!(notification_root(&comment, &lookup(&[])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
@@ -458,19 +518,6 @@ mod tests {
|
||||
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_update_resolves_via_uppercase_e() {
|
||||
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
|
||||
let update = signed(
|
||||
&keys(2),
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![uppercase_e_tag(&pr)],
|
||||
200,
|
||||
);
|
||||
let events = [pr.clone(), update.clone()];
|
||||
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
@@ -481,89 +528,56 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_excludes_self_and_sorts_groups_newest_first() {
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
let other_issue = signed(
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
|
||||
|
||||
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
events,
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
// The issue itself plus the comment; the self-authored comment is out.
|
||||
assert_eq!(items[0].events.len(), 2);
|
||||
assert_eq!(items[1].root, other_issue.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reports_unread_oldest_first_and_archived() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
|
||||
let events = [issue.clone(), older.clone(), newer.clone()];
|
||||
let items = group(
|
||||
events,
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
|
||||
assert!(!items[0].archived);
|
||||
assert!(items[0].is_unread());
|
||||
|
||||
let state = InboxReadState {
|
||||
archived_before: Timestamp::from_secs(1000),
|
||||
..Default::default()
|
||||
};
|
||||
let items = group(
|
||||
[issue.clone(), older, newer],
|
||||
me.public_key(),
|
||||
&state,
|
||||
&lookup(&[]),
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
// The own events are kept apart from the notifications, newest first.
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
assert!(items[0].archived);
|
||||
assert!(!items[0].unread_ids.is_empty());
|
||||
assert!(!items[0].is_unread());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reads_root_kind_and_address_from_the_root_event() {
|
||||
let me = keys(1);
|
||||
let owner_keys = keys(2);
|
||||
let owner = owner_keys.public_key();
|
||||
let issue = signed(
|
||||
&owner_keys,
|
||||
Kind::GitIssue,
|
||||
vec![a_tag(&owner, "my-repo")],
|
||||
100,
|
||||
);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
|
||||
let events = [issue.clone(), comment];
|
||||
let items = group(
|
||||
events.clone(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&events),
|
||||
);
|
||||
|
||||
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].address, issue.tags.coordinates().next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -655,45 +669,4 @@ mod tests {
|
||||
state.mark_archived(&event);
|
||||
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_state_recomputes_unread_and_archived() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let first = issue(&keys(2), now.as_secs() - 2000);
|
||||
let second = issue(&keys(2), now.as_secs() - 1000);
|
||||
let mut item = InboxItem {
|
||||
root: first.id,
|
||||
root_kind: None,
|
||||
address: None,
|
||||
events: vec![second.clone(), first.clone()],
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
|
||||
let state = InboxReadState {
|
||||
read_before: first.created_at,
|
||||
..Default::default()
|
||||
};
|
||||
item.apply_state(&state);
|
||||
|
||||
assert_eq!(item.unread_ids, vec![second.id]);
|
||||
assert!(!item.archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_preserves_state() {
|
||||
let first = issue(&keys(1), 100);
|
||||
let second = issue(&keys(2), 200);
|
||||
let state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(150),
|
||||
read_ids: HashSet::from([second.id]),
|
||||
archived_before: Timestamp::from_secs(50),
|
||||
archived_ids: HashSet::from([first.id]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&state).expect("serialized");
|
||||
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
|
||||
|
||||
assert_eq!(parsed, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod inbox;
|
||||
@@ -9,8 +7,6 @@ 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 deletions::Deletions;
|
||||
pub use filters::{
|
||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||
|
||||
@@ -41,13 +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 {
|
||||
/// Parse the `u` tag values.
|
||||
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>()
|
||||
@@ -56,11 +53,9 @@ impl Upstream {
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for display.
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
@@ -143,7 +138,6 @@ pub fn pull_request_patches<'a>(
|
||||
series
|
||||
}
|
||||
|
||||
/// The patch content of a pull request.
|
||||
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = pull_request_patches(pr, patches.iter().copied());
|
||||
@@ -320,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,7 +336,6 @@ impl Announcement {
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository address of this announcement.
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
repo_addr(self.owner, self.id.clone())
|
||||
}
|
||||
@@ -409,7 +402,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a signed kind `30617` event from raw tag values.
|
||||
fn announcement_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
@@ -469,22 +461,6 @@ mod tests {
|
||||
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_d_tag() {
|
||||
let event = announcement_event(&[&["name", "No id"]]);
|
||||
|
||||
assert!(Announcement::from_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_kinds() {
|
||||
let event = EventBuilder::new(Kind::GitIssue, "")
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
|
||||
assert!(Announcement::from_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_malformed_values() {
|
||||
let event = announcement_event(&[
|
||||
@@ -505,17 +481,6 @@ mod tests {
|
||||
assert!(announcement.maintainers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_tags() {
|
||||
let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert_eq!(announcement.id, "my-repo");
|
||||
assert!(announcement.name.is_none());
|
||||
assert!(announcement.web.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_upstream_tag() {
|
||||
let event = announcement_event(&[
|
||||
@@ -543,35 +508,12 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_git_url_upstream() {
|
||||
// The `u` tag may reference a non-nostr upstream by git URL only.
|
||||
// There is no repository address to navigate to.
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", "https://example.com/upstream.git"],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let upstream = announcement.upstream.expect("parses the u tag");
|
||||
|
||||
assert_eq!(upstream.addr, None);
|
||||
assert_eq!(
|
||||
upstream.display().to_string(),
|
||||
"https://example.com/upstream.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||
// The base repository, announced by the `u` tag's owner.
|
||||
@@ -632,17 +574,6 @@ mod tests {
|
||||
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_excludes_the_base_itself() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||
let base = Announcement::from_event(&event).expect("parses");
|
||||
let base_addr = base.addr();
|
||||
|
||||
// The base announcement matches its own EUC but is not a fork of itself.
|
||||
assert!(!base.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||
@@ -677,7 +608,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a signed PR event with the given tags and content.
|
||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(Kind::GitPullRequest, content)
|
||||
.tags(tags)
|
||||
@@ -685,35 +615,6 @@ mod tests {
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_prefers_linked_patch_event() {
|
||||
let patch = EventBuilder::new(Kind::GitPatch, "patch-content")
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
let pr = pr_event("description", vec![Tag::event(patch.id)]);
|
||||
|
||||
assert_eq!(pull_request_patch(&pr, [&patch]), "patch-content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_falls_back_to_inline_content() {
|
||||
// Older PRs carried the patch in the content and link no patch event.
|
||||
let pr = pr_event("patch-inline", vec![]);
|
||||
|
||||
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_ignores_unrelated_patch_events() {
|
||||
let patch = EventBuilder::new(Kind::GitPatch, "patch-content")
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
let pr = pr_event("description", vec![]);
|
||||
|
||||
assert_eq!(pull_request_patch(&pr, [&patch]), "description");
|
||||
}
|
||||
|
||||
/// Build a signed patch event with a controlled `created_at`.
|
||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(Kind::GitPatch, content)
|
||||
.tags(tags)
|
||||
@@ -757,24 +658,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_ignores_unrelated_replies() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let other = patch_event("other-patch", vec![Tag::event(root.id)], 250);
|
||||
// A patch replying to a different root is not part of the set.
|
||||
let stranger = patch_event("stranger", vec![], 150);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
let series = pull_request_patches(&pr, [&root, &other, &stranger]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "other-patch"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
// PRs without an `e` tag fall back to the patch producing the tip commit.
|
||||
@@ -807,7 +690,6 @@ mod tests {
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
/// Build a signed event of `kind` with the given tags and `created_at`.
|
||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
@@ -827,20 +709,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_current_commit_and_branch_name() {
|
||||
let pr = pr_root();
|
||||
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
|
||||
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_without_pr_tags() {
|
||||
let pr = signed_at(Kind::GitPullRequest, vec![], 100);
|
||||
assert_eq!(current_commit_of(&pr), None);
|
||||
assert_eq!(branch_name_of(&pr), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
@@ -886,19 +754,12 @@ mod tests {
|
||||
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_ignores_roots_without_revisions() {
|
||||
let root = pr_root();
|
||||
assert!(latest_update([&root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
const OWNER_KEYS: [&str; 3] = [
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
];
|
||||
|
||||
/// Build a signed kind-30617 event for `owner` with the given tags.
|
||||
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
|
||||
let tags: Vec<Tag> = tags
|
||||
@@ -960,7 +821,6 @@ mod tests {
|
||||
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
||||
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
|
||||
|
||||
// The user's fork comes first, then the other author's.
|
||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a signed kind `30618` event from raw tag values.
|
||||
fn state_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
@@ -90,26 +89,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_without_prefix_is_ignored() {
|
||||
let event = state_event(&[&["HEAD", "main"]]);
|
||||
|
||||
let (refs, head) = parse_state(&event);
|
||||
|
||||
assert!(refs.is_empty());
|
||||
assert!(head.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_state_tags() {
|
||||
let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]);
|
||||
|
||||
let (refs, head) = parse_state(&event);
|
||||
|
||||
assert!(refs.is_empty());
|
||||
assert!(head.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_state_round_trips_through_parse() {
|
||||
let refs = [
|
||||
@@ -129,17 +108,4 @@ mod tests {
|
||||
assert_eq!(parsed_refs, refs);
|
||||
assert_eq!(head.as_deref(), Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_state_omits_head_when_detached() {
|
||||
let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())];
|
||||
|
||||
let event = build_state("my-repo", &refs, None)
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
|
||||
let (parsed_refs, head) = parse_state(&event);
|
||||
assert_eq!(parsed_refs, refs);
|
||||
assert!(head.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ mod tests {
|
||||
EventId::from_hex(ROOT_ID_HEX).expect("valid event id")
|
||||
}
|
||||
|
||||
/// Build a signed status event with a controlled `created_at`.
|
||||
fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags([Tag::event(root)])
|
||||
@@ -102,53 +101,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_root_matches_uppercase_e_tag() {
|
||||
let root = root_event_id();
|
||||
let event = EventBuilder::new(Kind::Comment, "")
|
||||
.tags([Tag::parse(["E", ROOT_ID_HEX]).expect("valid E tag")])
|
||||
.finalize(&keys_from_hex(
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
))
|
||||
.expect("signed event");
|
||||
|
||||
assert!(references_root(&event, &root));
|
||||
assert!(!references_root(
|
||||
&event,
|
||||
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_root_false_without_e_tags() {
|
||||
let event = EventBuilder::new(Kind::GitStatusOpen, "")
|
||||
.finalize(&keys_from_hex(
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
))
|
||||
.expect("signed event");
|
||||
|
||||
assert!(!references_root(&event, &root_event_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_open_without_status_events() {
|
||||
let owner =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
|
||||
let maintainer =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
|
||||
let statuses: Vec<Event> = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(
|
||||
statuses.iter(),
|
||||
&owner.public_key(),
|
||||
&[maintainer.public_key()]
|
||||
),
|
||||
RepoStatus::Open
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_status_wins() {
|
||||
let owner =
|
||||
@@ -196,18 +148,4 @@ mod tests {
|
||||
RepoStatus::Draft
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_status_kinds() {
|
||||
let owner =
|
||||
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
|
||||
let root = root_event_id();
|
||||
|
||||
let statuses = [status_event(&owner, Kind::GitIssue, root, 100)];
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(statuses.iter(), &owner.public_key(), &[]),
|
||||
RepoStatus::Open
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -16,19 +16,16 @@ impl GitCache {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// The root directory holding the mirror clones.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Local path of the clone for a repository.
|
||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||
self.root
|
||||
.join(addr.public_key.to_hex())
|
||||
.join(sanitize_path_component(&addr.identifier))
|
||||
}
|
||||
|
||||
/// Open an existing clone.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||
let path = self.repo_path(addr);
|
||||
match gix::open(&path) {
|
||||
@@ -64,9 +61,6 @@ impl GitCache {
|
||||
}
|
||||
|
||||
/// Map an untrusted repository id or display name to a safe single path component.
|
||||
///
|
||||
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
||||
/// An id that maps to exactly `.` or `..` becomes `_`.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
|
||||
@@ -3,18 +3,14 @@ use std::path::Path;
|
||||
use anyhow::Result;
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
|
||||
/// The kind of a [`DiffLine`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
/// An unchanged context line, present on both sides.
|
||||
Context,
|
||||
/// A line added by the commit.
|
||||
Addition,
|
||||
/// A line removed by the commit.
|
||||
Deletion,
|
||||
}
|
||||
|
||||
/// One line of a file diff.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
@@ -31,16 +27,13 @@ pub struct DiffLine {
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
/// Number of old lines covered by the hunk.
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
/// Number of new lines covered by the hunk.
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
/// How a file changed in a commit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
@@ -50,7 +43,6 @@ pub enum DiffStatus {
|
||||
Copied,
|
||||
}
|
||||
|
||||
/// The diff of one file in a commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root.
|
||||
@@ -69,7 +61,6 @@ pub struct FileDiff {
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
/// The changes of one commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
@@ -110,7 +101,7 @@ pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Resu
|
||||
.tree()?;
|
||||
tree_diff(&repo, Some(&base_tree), &tip_tree)
|
||||
}
|
||||
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
||||
|
||||
fn tree_diff(
|
||||
repo: &gix::Repository,
|
||||
old_tree: Option<&gix::Tree<'_>>,
|
||||
|
||||
@@ -20,7 +20,6 @@ pub struct FileCommit {
|
||||
///
|
||||
/// `None` for single-line commit messages.
|
||||
pub description: Option<String>,
|
||||
/// Author name.
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
pub time: i64,
|
||||
@@ -36,7 +35,7 @@ pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
|
||||
/// A [`FileCommit`] with author, message title, body and shortened id.
|
||||
///
|
||||
/// The diff panel fetches the full commit on demand.
|
||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
@@ -50,7 +49,6 @@ fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
file_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body.
|
||||
fn file_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
@@ -74,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.
|
||||
///
|
||||
@@ -96,8 +83,6 @@ pub fn worktree_last_commits(
|
||||
last_commits(&open_with_cache(workdir)?, rels)
|
||||
}
|
||||
|
||||
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
||||
///
|
||||
/// Stops as soon as every pending path has its commit.
|
||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
@@ -106,7 +91,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// De-duplicate while preserving order.
|
||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||
|
||||
@@ -136,7 +120,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
|
||||
};
|
||||
|
||||
// Compare each unresolved path against this commit and its first parent.
|
||||
// Resolved paths leave the pending set.
|
||||
let mut ix = 0;
|
||||
while ix < pending.len() {
|
||||
let rel = &pending[ix];
|
||||
@@ -206,8 +189,6 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
/// Like [`all_commits`], but opens the repository at `workdir` first.
|
||||
///
|
||||
/// For non-bare clones the clone root is the worktree.
|
||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||
all_commits(&open_with_cache(workdir)?)
|
||||
|
||||
@@ -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,14 +36,12 @@ 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,
|
||||
};
|
||||
|
||||
/// Run a git command in `dir`, returning trimmed stdout.
|
||||
///
|
||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
||||
#[cfg(test)]
|
||||
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
|
||||
@@ -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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,11 @@ use diffy::{Hunk, Line};
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::history::FileCommit;
|
||||
|
||||
/// Apply a `git format-patch` patch or series with `git am`,
|
||||
/// uses the git CLI because it handles the mbox format natively.
|
||||
/// Apply a `git format-patch` patch or series with `git am`.
|
||||
///
|
||||
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
|
||||
/// Uses the git CLI because it handles the mbox format natively.
|
||||
///
|
||||
/// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("am")
|
||||
@@ -116,7 +117,6 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// The [`FileDiff`] of one parsed file patch.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
@@ -296,7 +296,6 @@ pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
commits
|
||||
}
|
||||
|
||||
/// The name part of a `From: Name <email>` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
|
||||
@@ -41,7 +41,6 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
||||
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -107,7 +106,6 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
@@ -131,7 +129,6 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||
fn push_refspecs(
|
||||
repo_path: &Path,
|
||||
base_url: &str,
|
||||
@@ -255,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<()> {
|
||||
|
||||
@@ -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};
|
||||
@@ -55,7 +55,6 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>
|
||||
};
|
||||
|
||||
let Some(base) = base else {
|
||||
// `HEAD` alone when no base is given.
|
||||
return Ok(vec![head.to_string()]);
|
||||
};
|
||||
|
||||
@@ -168,16 +167,11 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
|
||||
)?;
|
||||
|
||||
// Populate the index so the fresh repository is clean,
|
||||
// as `git add` and`git commit` would leave it.
|
||||
// as `git add` and `git commit` would leave it.
|
||||
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`.
|
||||
@@ -190,7 +184,6 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
};
|
||||
|
||||
let Ok(head) = repo.head_id() else {
|
||||
// An unborn HEAD with no commits yet has no root commit.
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -203,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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +226,6 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort lexicographically, like `git for-each-ref`.
|
||||
names.sort();
|
||||
|
||||
Ok(names)
|
||||
@@ -267,7 +258,6 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
// Delete all refs with the given prefix.
|
||||
repo.edit_references(edits)?;
|
||||
|
||||
Ok(())
|
||||
@@ -282,7 +272,6 @@ pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
|
||||
Some(String::from_utf8_lossy(name.shorten()).into_owned())
|
||||
}
|
||||
|
||||
/// Whether the reference `name` exists in the repository at `workdir`.
|
||||
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return false;
|
||||
@@ -374,10 +363,8 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
|
||||
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
|
||||
|
||||
// Check out the remote tree, discarding local changes.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
// Update the branch reference to point to the remote tree.
|
||||
repo.edit_references_as(
|
||||
[edit(gix::refs::Target::Object(remote_oid))],
|
||||
Some(signature),
|
||||
@@ -385,7 +372,6 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
|
||||
moved = true;
|
||||
} else {
|
||||
// Update the branch reference to point to the remote tree.
|
||||
repo.edit_references_as(
|
||||
[edit(gix::refs::Target::Object(remote_oid))],
|
||||
Some(signature),
|
||||
@@ -447,10 +433,6 @@ pub struct RepoRefState {
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
/// Collect the refs of `repo`.
|
||||
///
|
||||
/// Local branches and tags become `(refname, commit-id)` pairs.
|
||||
/// Also reports the branch HEAD points to.
|
||||
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||
let mut refs = Vec::new();
|
||||
|
||||
@@ -482,7 +464,6 @@ pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||
Ok(RepoRefState { refs, head })
|
||||
}
|
||||
|
||||
/// [`repo_ref_state`] for the repository at `workdir`.
|
||||
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
||||
repo_ref_state(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,21 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
/// Maximum directory nesting depth when scanning for local repositories.
|
||||
///
|
||||
/// Pathological trees can't stall the scan.
|
||||
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,
|
||||
/// honouring `.gitignore` (and `.ignore`) files.
|
||||
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();
|
||||
}
|
||||
@@ -40,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()
|
||||
}
|
||||
|
||||
+18
-745
@@ -2,24 +2,8 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use signed_core::{Announcement, repo_addr};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_plain_ids() {
|
||||
assert_eq!(sanitize_path_component("my-repo"), "my-repo");
|
||||
assert_eq!(sanitize_path_component("repo.v2"), "repo.v2");
|
||||
assert_eq!(sanitize_path_component("a_b-c"), "a_b-c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_unsafe_characters() {
|
||||
assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d");
|
||||
assert_eq!(sanitize_path_component(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_parent_components() {
|
||||
assert_eq!(sanitize_path_component(".."), "_");
|
||||
@@ -29,34 +13,6 @@ fn blocks_parent_components() {
|
||||
assert_eq!(sanitize_path_component("a/../b"), "a_.._b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_namespace_combines_owner_and_sanitized_id() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags([Tag::parse(["d", "my/repo"]).expect("valid tag")])
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert_eq!(
|
||||
fork_namespace(&announcement),
|
||||
format!("{}/my_repo", keys.public_key().to_hex())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_path_stays_inside_root() {
|
||||
let cache = GitCache::new("/cache".into());
|
||||
let owner = Keys::generate().public_key();
|
||||
|
||||
let path = cache.repo_path(&repo_addr(owner, ".."));
|
||||
assert!(path.starts_with("/cache"));
|
||||
assert_eq!(
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()),
|
||||
Some("_".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_discovers_repositories_recursively() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -74,23 +30,22 @@ fn find_git_repos_discovers_repositories_recursively() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Plain directories are not repositories.
|
||||
std::fs::create_dir_all(root.join("plain")).unwrap();
|
||||
|
||||
// A `.gitignore` at the root excludes dependency caches.
|
||||
std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
|
||||
std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap();
|
||||
|
||||
// Hidden entries are skipped.
|
||||
std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
|
||||
|
||||
// A repository is not descended into.
|
||||
// Repositories inside it, like submodule worktrees, are not reported.
|
||||
// A repository inside another, like a submodule worktree, is not reported.
|
||||
let outer = root.join("outer");
|
||||
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![
|
||||
@@ -120,13 +75,6 @@ fn root_commit_reports_the_first_ancestor() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_commit_is_none_without_commits() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
let workdir = repo.workdir().expect("workdir");
|
||||
assert_eq!(root_commit(workdir).expect("root"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_mirrors_branches_and_tags() {
|
||||
// A bare server repository reachable via a `file://` URL.
|
||||
@@ -145,7 +93,6 @@ fn push_all_mirrors_branches_and_tags() {
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
// Two branches plus a tag are all mirrored.
|
||||
git_run(dir, &["checkout", "-b", "feature"]);
|
||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "feature work");
|
||||
@@ -161,34 +108,6 @@ fn push_all_mirrors_branches_and_tags() {
|
||||
assert!(refs.contains("refs/tags/v1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_tolerates_a_missing_ref_kind() {
|
||||
// A repository with only tags and no branches still pushes.
|
||||
// Wildcard refspecs without a local match are ignored.
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = server.path().join("npub1test").join("my-repo.git");
|
||||
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
|
||||
let init_status = Command::new("git")
|
||||
.args(["init", "--bare", "-q"])
|
||||
.arg(&server_repo)
|
||||
.status()
|
||||
.expect("spawn git init --bare");
|
||||
assert!(init_status.success());
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
git_run(dir, &["update-ref", "-d", "refs/heads/main"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
|
||||
|
||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||
assert!(refs.contains("refs/tags/v1.0"));
|
||||
assert!(!refs.contains("refs/heads/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_has_refs_reports_whether_pushed_refs_landed() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
@@ -208,7 +127,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
|
||||
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
let expected = vec![("refs/heads/main".to_owned(), main.clone())];
|
||||
|
||||
// Nothing pushed yet: the ref is absent.
|
||||
assert!(!remote_has_refs(dir, &url, &expected).expect("probe"));
|
||||
|
||||
push_all(
|
||||
@@ -219,7 +137,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
|
||||
)
|
||||
.expect("push");
|
||||
|
||||
// The pushed ref is advertised at the expected commit.
|
||||
assert!(remote_has_refs(dir, &url, &expected).expect("probe"));
|
||||
|
||||
// A stale expectation - the exact race a retry resolves - is false.
|
||||
@@ -253,7 +170,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
|
||||
assert_eq!(state.refs[0].0, format!("refs/heads/{branch}"));
|
||||
assert_eq!(state.refs[0].1.len(), 40);
|
||||
|
||||
// Additional branches and tags are listed alongside.
|
||||
git_run(&workdir, &["branch", "feature"]);
|
||||
git_run(&workdir, &["tag", "v1.0"]);
|
||||
|
||||
@@ -273,7 +189,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
|
||||
expected
|
||||
);
|
||||
|
||||
// A detached HEAD yields no head branch.
|
||||
git_run(&workdir, &["checkout", "--detach"]);
|
||||
let state = repo_ref_state(&repo).expect("refs");
|
||||
assert!(state.head.is_none());
|
||||
@@ -294,52 +209,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) {
|
||||
(dir, repo)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_entries_lists_all_files_and_dirs() {
|
||||
let (_dir, repo) = fixture(&[
|
||||
("README.md", b"# Hi"),
|
||||
("src/main.rs", b"fn main() {}"),
|
||||
("src/lib.rs", b""),
|
||||
("docs/guide.md", b"guide"),
|
||||
]);
|
||||
|
||||
let entries = worktree_entries(&repo).expect("entries");
|
||||
let entries: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
"docs",
|
||||
"src",
|
||||
"README.md",
|
||||
"docs/guide.md",
|
||||
"src/lib.rs",
|
||||
"src/main.rs"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_read_returns_bytes_or_none() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]);
|
||||
|
||||
assert_eq!(
|
||||
worktree_read(&repo, Path::new("a.txt")).expect("read"),
|
||||
Some(b"hello".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
worktree_read(&repo, Path::new("sub/b.bin")).expect("read"),
|
||||
Some(vec![0x00, 0x01])
|
||||
);
|
||||
assert_eq!(
|
||||
worktree_read(&repo, Path::new("missing.txt")).expect("read"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Stage everything and create a commit with the git CLI.
|
||||
/// Like [`apply_patch`], the crate already shells out to the CLI.
|
||||
fn commit_all(repo: &gix::Repository, message: &str) {
|
||||
@@ -379,50 +248,6 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
|
||||
assert!(merge_base(&path, "orphan", "no-such-ref").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_patch_between_produces_the_series_and_rejects_empty_ranges() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
let initial = init_repository(&path, "My Repo", "desc").expect("init");
|
||||
|
||||
git_run(&path, &["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
|
||||
commit_all(&gix::open(&path).expect("open"), "feature commit");
|
||||
|
||||
let patch = format_patch_between(&path, &initial, "feature").expect("patch");
|
||||
assert!(patch.contains("Subject: [PATCH] feature commit"));
|
||||
assert!(patch.contains("feature.txt"));
|
||||
|
||||
// An empty range has no commits to send.
|
||||
assert!(format_patch_between(&path, "feature", "feature").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_commit_ref_pushes_to_the_event_namespace() {
|
||||
// A bare server repository reachable via a `file://` URL.
|
||||
// Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout.
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = server.path().join("npub1test").join("my-repo.git");
|
||||
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
|
||||
let init_status = Command::new("git")
|
||||
.args(["init", "--bare", "-q"])
|
||||
.arg(&server_repo)
|
||||
.status()
|
||||
.expect("spawn git init --bare");
|
||||
assert!(init_status.success());
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip");
|
||||
|
||||
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push");
|
||||
|
||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||
assert!(refs.contains("refs/nostr/abcd1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_patch_series_splits_real_multi_commit_mboxes() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -448,14 +273,6 @@ fn split_patch_series_splits_real_multi_commit_mboxes() {
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_patch_series_keeps_single_patches_whole() {
|
||||
let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A <a@b>\nSubject: [PATCH] fix\n\n---\n";
|
||||
let parts = split_patch_series(patch);
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(parts[0], patch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_commit_and_commits_since_track_applied_commits() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -466,7 +283,7 @@ fn head_commit_and_commits_since_track_applied_commits() {
|
||||
head_commit_id(&path).expect("head").as_deref(),
|
||||
Some(initial.as_str())
|
||||
);
|
||||
// No commits yet, `HEAD` alone.
|
||||
// No base given, `HEAD` alone.
|
||||
assert_eq!(
|
||||
commits_since(&path, None).expect("commits"),
|
||||
vec![initial.clone()]
|
||||
@@ -491,24 +308,6 @@ fn head_commit_and_commits_since_track_applied_commits() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_commit_reports_unborn_repositories() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
let status = Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.arg(&path)
|
||||
.status()
|
||||
.expect("spawn git init");
|
||||
assert!(status.success());
|
||||
|
||||
assert_eq!(head_commit_id(&path).expect("head"), None);
|
||||
assert_eq!(
|
||||
commits_since(&path, None).expect("commits"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_repository_creates_main_branch_and_readme() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -541,70 +340,12 @@ fn init_repository_creates_main_branch_and_readme() {
|
||||
assert!(!worktree_dirty(workdir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_repository_omits_description_when_empty() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("my-repo");
|
||||
|
||||
init_repository(&path, "My Repo", " ").expect("init");
|
||||
let repo = gix::open(&path).expect("open");
|
||||
let workdir = repo.workdir().expect("workdir");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workdir.join("README.md")).expect("read"),
|
||||
"# My Repo\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_origin_adds_remote_only_once() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("my-repo");
|
||||
init_repository(&path, "My Repo", "").expect("init");
|
||||
|
||||
ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add");
|
||||
assert_eq!(
|
||||
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
|
||||
"https://gitnostr.com/npub1test/repo.git"
|
||||
);
|
||||
// The standard fetch mapping is configured with the remote.
|
||||
// Later `git fetch origin` updates `refs/remotes/origin/*`.
|
||||
assert_eq!(
|
||||
git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"),
|
||||
"+refs/heads/*:refs/remotes/origin/*"
|
||||
);
|
||||
|
||||
// A second call must not override the existing remote.
|
||||
ensure_origin(&path, "https://other.example/repo.git").expect("keep");
|
||||
assert_eq!(
|
||||
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
|
||||
"https://gitnostr.com/npub1test/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_url_reads_the_remote_or_reports_none() {
|
||||
let (dir, _repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&_repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
// No remote configured yet.
|
||||
assert_eq!(origin_url(dir).expect("read"), None);
|
||||
|
||||
ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add");
|
||||
assert_eq!(
|
||||
origin_url(dir).expect("read").as_deref(),
|
||||
Some("https://gitnostr.com/npub1test/repo.git")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_origin_creates_or_replaces_the_remote() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("my-repo");
|
||||
init_repository(&path, "My Repo", "").expect("init");
|
||||
|
||||
// No origin yet, so one is added.
|
||||
set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add");
|
||||
assert_eq!(
|
||||
origin_url(&path).expect("url").as_deref(),
|
||||
@@ -664,7 +405,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
.expect("spawn git init --bare");
|
||||
assert!(init_status.success());
|
||||
|
||||
// The owner's working repo pushes the initial commit.
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work = work_dir.path();
|
||||
@@ -746,7 +486,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
)
|
||||
.expect("push");
|
||||
|
||||
// The base mirror is a plain clone of the base server.
|
||||
let base_url = format!("file://{}", base_server.display());
|
||||
let mirror = dir.path().join("mirror");
|
||||
git_run(
|
||||
@@ -792,7 +531,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
)
|
||||
.expect("fetch");
|
||||
|
||||
// The imported refs are listed under the prefix only.
|
||||
assert_eq!(
|
||||
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
|
||||
vec!["refs/fork/npub1fork/fork/feature"]
|
||||
@@ -820,7 +558,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
assert!(patch.contains("Subject: [PATCH] feature commit"));
|
||||
assert!(patch.contains("feature.txt"));
|
||||
|
||||
// Pruning the prefix removes the import again.
|
||||
delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete");
|
||||
assert_eq!(
|
||||
refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"),
|
||||
@@ -828,30 +565,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_repo_refs_fails_when_every_url_fails() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = _dir.path();
|
||||
|
||||
let dead = format!("file://{}/missing.git", dir.display());
|
||||
let err =
|
||||
fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*").expect_err("all URLs fail");
|
||||
assert!(err.to_string().contains("failed to fetch"));
|
||||
|
||||
// Without any URL there is nothing to try.
|
||||
let err =
|
||||
fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs");
|
||||
assert!(err.to_string().contains("no clone URLs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_refs_with_prefix_is_a_noop_without_matches() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop");
|
||||
}
|
||||
|
||||
/// Run a git command in `dir`, asserting success.
|
||||
fn git_run(dir: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
@@ -868,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");
|
||||
|
||||
@@ -879,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());
|
||||
@@ -911,26 +627,7 @@ fn all_commits_lists_every_commit() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_commits_returns_empty_without_head() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
|
||||
let list = all_commits(&repo).expect("commits");
|
||||
assert!(list.commits.is_empty());
|
||||
assert_eq!(list.total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_returns_none_for_untracked_files() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write");
|
||||
|
||||
let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup");
|
||||
assert!(commit.is_none());
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
@@ -954,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()
|
||||
@@ -964,35 +664,6 @@ fn last_commit_reports_merge_commits() {
|
||||
assert!(commit.summary.starts_with("Merge branch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commits_batches_multiple_paths() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
std::fs::write(dir.path().join("a.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "change a");
|
||||
std::fs::write(dir.path().join("b.txt"), b"bb").expect("write");
|
||||
commit_all(&repo, "change b");
|
||||
|
||||
let found = worktree_last_commits(
|
||||
dir.path(),
|
||||
&[
|
||||
PathBuf::from("a.txt"),
|
||||
PathBuf::from("b.txt"),
|
||||
// Untracked paths are absent from the result.
|
||||
PathBuf::from("missing.txt"),
|
||||
],
|
||||
)
|
||||
.expect("commits");
|
||||
let by_path: HashMap<&Path, &FileCommit> = found
|
||||
.iter()
|
||||
.map(|(path, commit)| (path.as_path(), commit))
|
||||
.collect();
|
||||
assert_eq!(by_path.len(), 2);
|
||||
assert_eq!(by_path[Path::new("a.txt")].summary, "change a");
|
||||
assert_eq!(by_path[Path::new("b.txt")].summary, "change b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_readme_prefers_markdown() {
|
||||
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
|
||||
@@ -1004,63 +675,6 @@ fn find_readme_prefers_markdown() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_readme_falls_back_to_any_readme() {
|
||||
let (_dir, repo) = fixture(&[("README.rst", b"rst")]);
|
||||
|
||||
let readme = find_readme(&repo).expect("find");
|
||||
assert_eq!(
|
||||
readme.map(|p| p.to_string_lossy().into_owned()),
|
||||
Some("README.rst".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_readme_returns_none_without_one() {
|
||||
let (_dir, repo) = fixture(&[("main.rs", b"")]);
|
||||
assert!(find_readme(&repo).expect("find").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_commit_reports_head() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
|
||||
// Unborn HEAD means no commit yet.
|
||||
assert!(head_commit(&repo).expect("head").is_none());
|
||||
|
||||
commit_all(&repo, "initial");
|
||||
let head = head_commit(&repo).expect("head").expect("commit");
|
||||
assert_eq!(
|
||||
head.id,
|
||||
repo.head_id().expect("head id").shorten_or_id().to_string()
|
||||
);
|
||||
assert_eq!(head.summary, "initial");
|
||||
assert_eq!(head.author, "Test Author");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_branches_and_tags_list_short_names() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["checkout", "-b", "feature"]);
|
||||
git_run(dir, &["tag", "v0.9"]);
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
|
||||
// The initial branch name depends on git configuration.
|
||||
// Only the branch we created is fixed.
|
||||
let branches = worktree_branches(dir).expect("branches");
|
||||
assert_eq!(branches.len(), 2);
|
||||
assert!(branches.contains(&"feature".to_string()));
|
||||
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
|
||||
|
||||
assert_eq!(
|
||||
repo_tags(&repo).expect("tags"),
|
||||
vec!["v0.9".to_string(), "v1.0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_branch_tracks_checkout() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
@@ -1083,12 +697,10 @@ fn current_branch_tracks_checkout() {
|
||||
Some("feature")
|
||||
);
|
||||
|
||||
// Tags detach HEAD.
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
worktree_checkout_tag(dir, "v1.0").expect("checkout tag");
|
||||
assert_eq!(current_branch(&repo).expect("branch"), None);
|
||||
|
||||
// Branches re-attach HEAD.
|
||||
worktree_checkout_branch(dir, &default).expect("checkout branch");
|
||||
assert_eq!(
|
||||
current_branch(&repo).expect("branch").as_deref(),
|
||||
@@ -1239,25 +851,6 @@ fn commit_range_diff_lists_changes_between_two_commits() {
|
||||
assert!(diff.files.iter().all(|file| file.path != "b.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_range_commits_lists_only_new_commits_newest_first() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
|
||||
commit_all(&repo, "one");
|
||||
let base = repo.head_id().expect("head").to_string();
|
||||
|
||||
std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write");
|
||||
commit_all(&repo, "two");
|
||||
std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write");
|
||||
commit_all(&repo, "three");
|
||||
let tip = repo.head_id().expect("head").to_string();
|
||||
|
||||
let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits");
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].summary, "three");
|
||||
assert_eq!(commits[1].summary, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_reports_binary_files_without_hunks() {
|
||||
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
|
||||
@@ -1279,56 +872,6 @@ fn commit_diff_reports_binary_files_without_hunks() {
|
||||
assert_eq!(file.deletions, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_resolves_short_ids_and_root_commit() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
// The root commit diffs against the empty tree, everything is added.
|
||||
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
|
||||
assert_eq!(diff.files.len(), 1);
|
||||
assert_eq!(diff.files[0].path, "a.txt");
|
||||
assert_eq!(diff.files[0].status, DiffStatus::Added);
|
||||
assert_eq!(diff.files[0].insertions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_commit_includes_message_body() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "title");
|
||||
|
||||
// A single-line message has no body.
|
||||
let head = head_commit(&repo).expect("head").expect("commit");
|
||||
assert_eq!(head.summary, "title");
|
||||
assert_eq!(head.description, None);
|
||||
|
||||
// A message with a body exposes it, trimmed.
|
||||
let dir = _dir.path();
|
||||
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([
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"title two",
|
||||
"-m",
|
||||
"line one\n\nline two",
|
||||
])
|
||||
.status()
|
||||
.expect("spawn git");
|
||||
assert!(status.success(), "git commit failed");
|
||||
|
||||
let head = head_commit(&repo).expect("head").expect("commit");
|
||||
assert_eq!(head.summary, "title two");
|
||||
assert_eq!(head.description.as_deref(), Some("line one\n\nline two"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_reports_renames() {
|
||||
let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]);
|
||||
@@ -1352,143 +895,6 @@ fn commit_diff_reports_renames() {
|
||||
assert_eq!(file.deletions, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_format_patch_output() {
|
||||
let patch = r#"From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001
|
||||
From: A <a@b.c>
|
||||
Subject: [PATCH] fix
|
||||
|
||||
fix the thing
|
||||
|
||||
---
|
||||
src/lib.rs | 2 +-
|
||||
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
|
||||
diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 1234567..89abcde 100644
|
||||
--- a/src/lib.rs
|
||||
+++ b/src/lib.rs
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
- println!("old");
|
||||
+ println!("new");
|
||||
}
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
assert_eq!(diff.files.len(), 1);
|
||||
let file = &diff.files[0];
|
||||
assert_eq!(file.path, "src/lib.rs");
|
||||
assert_eq!(file.old_path, None);
|
||||
assert_eq!(file.status, DiffStatus::Modified);
|
||||
assert_eq!(file.insertions, 1);
|
||||
assert_eq!(file.deletions, 1);
|
||||
|
||||
let hunk = &file.hunks[0];
|
||||
assert_eq!(hunk.old_start, 1);
|
||||
assert_eq!(hunk.old_lines, 3);
|
||||
assert_eq!(hunk.new_start, 1);
|
||||
assert_eq!(hunk.new_lines, 3);
|
||||
assert_eq!(hunk.lines.len(), 4);
|
||||
assert_eq!(hunk.lines[0].kind, DiffLineKind::Context);
|
||||
assert_eq!(hunk.lines[0].old, Some(1));
|
||||
assert_eq!(hunk.lines[0].new, Some(1));
|
||||
assert_eq!(hunk.lines[1].kind, DiffLineKind::Deletion);
|
||||
assert_eq!(hunk.lines[1].old, Some(2));
|
||||
assert_eq!(hunk.lines[1].new, None);
|
||||
assert_eq!(hunk.lines[2].kind, DiffLineKind::Addition);
|
||||
assert_eq!(hunk.lines[2].old, None);
|
||||
assert_eq!(hunk.lines[2].new, Some(2));
|
||||
assert_eq!(hunk.lines[3].kind, DiffLineKind::Context);
|
||||
assert_eq!(hunk.lines[3].old, Some(3));
|
||||
assert_eq!(hunk.lines[3].new, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_new_file_as_added() {
|
||||
let patch = r#"diff --git a/README.md b/README.md
|
||||
new file mode 100644
|
||||
index 0000000..1234567
|
||||
--- /dev/null
|
||||
+++ b/README.md
|
||||
@@ -0,0 +1 @@
|
||||
+# hello
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
let file = &diff.files[0];
|
||||
assert_eq!(file.path, "README.md");
|
||||
assert_eq!(file.status, DiffStatus::Added);
|
||||
assert_eq!(file.old_path, None);
|
||||
assert_eq!(file.insertions, 1);
|
||||
assert_eq!(file.deletions, 0);
|
||||
assert_eq!(file.hunks[0].old_start, 0);
|
||||
assert_eq!(file.hunks[0].old_lines, 0);
|
||||
assert_eq!(file.hunks[0].new_start, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_renames_with_old_path() {
|
||||
let patch = r#"diff --git a/old.rs b/new.rs
|
||||
similarity index 85%
|
||||
rename from old.rs
|
||||
rename to new.rs
|
||||
index 123..456 100644
|
||||
--- a/old.rs
|
||||
+++ b/new.rs
|
||||
@@ -1 +1 @@
|
||||
-fn main() {}
|
||||
+fn main() { println!("hi"); }
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
let file = &diff.files[0];
|
||||
assert_eq!(file.path, "new.rs");
|
||||
assert_eq!(file.old_path.as_deref(), Some("old.rs"));
|
||||
assert_eq!(file.status, DiffStatus::Renamed);
|
||||
assert_eq!(file.insertions, 1);
|
||||
assert_eq!(file.deletions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_patch_series_and_skips_envelope() {
|
||||
let patch = r#"From aaaa Mon Sep 17 00:00:00 2001
|
||||
From: A <a@b.c>
|
||||
Subject: [PATCH 1/2] one
|
||||
|
||||
---
|
||||
a.txt | 1 +
|
||||
1 file changed, 1 insertion(+)
|
||||
|
||||
diff --git a/a.txt b/a.txt
|
||||
index 1..2 100644
|
||||
--- a/a.txt
|
||||
+++ b/a.txt
|
||||
@@ -1 +1,2 @@
|
||||
a
|
||||
+b
|
||||
|
||||
From bbbb Mon Sep 17 00:00:00 2001
|
||||
From: A <a@b.c>
|
||||
Subject: [PATCH 2/2] two
|
||||
|
||||
diff --git a/b.txt b/b.txt
|
||||
index 3..4 100644
|
||||
--- a/b.txt
|
||||
+++ b/b.txt
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
+y
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
assert_eq!(diff.files.len(), 2);
|
||||
assert_eq!(diff.files[0].path, "a.txt");
|
||||
assert_eq!(diff.files[0].insertions, 1);
|
||||
assert_eq!(diff.files[1].path, "b.txt");
|
||||
assert_eq!(diff.files[1].deletions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_commits_lists_every_patch_in_order() {
|
||||
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
|
||||
@@ -1536,119 +942,6 @@ diff --git a/b.txt b/b.txt
|
||||
assert_eq!(commits[1].time, 1690975800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_commits_strips_patch_subject_prefixes() {
|
||||
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
|
||||
From: A <a@b.c>
|
||||
Subject: [RFC PATCH v3 4/7] the real title
|
||||
|
||||
---
|
||||
"#;
|
||||
|
||||
let commits = patch_commits(patch);
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].summary, "the real title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_commits_handles_missing_headers() {
|
||||
// A hand-written patch without author or date headers still lists a commit.
|
||||
// Time stays 0 and the author stays empty.
|
||||
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
|
||||
Subject: [PATCH] plain
|
||||
|
||||
---
|
||||
"#;
|
||||
|
||||
let commits = patch_commits(patch);
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].summary, "plain");
|
||||
assert_eq!(commits[0].author, "");
|
||||
assert_eq!(commits[0].time, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_commits_ignores_non_patch_lines() {
|
||||
assert!(patch_commits("").is_empty());
|
||||
assert!(patch_commits("just some text\nFrom 123\n").is_empty());
|
||||
// A diff-only body without an mbox envelope has no commits.
|
||||
let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n";
|
||||
assert!(patch_commits(patch).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_binary_sections() {
|
||||
let patch = r#"diff --git a/img.png b/img.png
|
||||
index 123..456 100644
|
||||
Binary files a/img.png and b/img.png differ
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
assert!(diff.files[0].binary);
|
||||
assert!(diff.files[0].hunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquotes_quoted_paths() {
|
||||
let patch = r#"diff --git "a/weird file.rs" "b/weird file.rs"
|
||||
index 123..456 100644
|
||||
--- "a/weird file.rs"
|
||||
+++ "b/weird file.rs"
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
+y
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
assert_eq!(diff.files[0].path, "weird file.rs");
|
||||
assert_eq!(diff.files[0].status, DiffStatus::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquotes_non_ascii_quoted_paths() {
|
||||
let patch = r#"diff --git "a/说明.md" "b/说明.md"
|
||||
index 123..456 100644
|
||||
--- "a/说明.md"
|
||||
+++ "b/说明.md"
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
+y
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
assert_eq!(diff.files[0].path, "说明.md");
|
||||
assert_eq!(diff.files[0].status, DiffStatus::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquotes_octal_escaped_paths() {
|
||||
let patch = r#"diff --git "a/\345\270\226.md" "b/\345\270\226.md"
|
||||
index 123..456 100644
|
||||
--- "a/\345\270\226.md"
|
||||
+++ "b/\345\270\226.md"
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
+y
|
||||
"#;
|
||||
let diff = patch_diffs(patch).expect("parse");
|
||||
|
||||
assert_eq!(diff.files[0].path, "帖.md");
|
||||
assert_eq!(diff.files[0].status, DiffStatus::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_unparseable_patch_yields_no_files() {
|
||||
assert_eq!(patch_diffs("").expect("parse").files.len(), 0);
|
||||
assert_eq!(patch_diffs("just some text").expect("parse").files.len(), 0);
|
||||
assert_eq!(
|
||||
patch_diffs("---\nnot a patch\n")
|
||||
.expect("parse")
|
||||
.files
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_format_patch_output() {
|
||||
// Build a commit touching a mix of file kinds.
|
||||
@@ -1735,7 +1028,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
|
||||
|
||||
assert!(!worktree_dirty(workdir));
|
||||
|
||||
// A modified tracked file is dirty.
|
||||
std::fs::write(workdir.join("tracked.txt"), b"two").expect("write");
|
||||
assert!(worktree_dirty(workdir));
|
||||
|
||||
@@ -1745,7 +1037,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
|
||||
std::fs::write(workdir.join("untracked.txt"), b"new").expect("write");
|
||||
assert!(worktree_dirty(workdir));
|
||||
|
||||
// A staged change counts too.
|
||||
git_run(workdir, &["rm", "--cached", "tracked.txt"]);
|
||||
assert!(worktree_dirty(workdir));
|
||||
|
||||
@@ -1753,24 +1044,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() {
|
||||
assert!(!worktree_dirty(&dir.path().join("missing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_dirty_reports_unborn_worktrees_with_files() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
let status = Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.arg(&path)
|
||||
.status()
|
||||
.expect("spawn git init");
|
||||
assert!(status.success());
|
||||
|
||||
// No commits and no files: porcelain is empty.
|
||||
assert!(!worktree_dirty(&path));
|
||||
// An unborn repository holding files is dirty.
|
||||
std::fs::write(path.join("README.md"), "# hello\n").expect("write");
|
||||
assert!(worktree_dirty(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_commits_ahead_counts_branch_only_commits() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
|
||||
@@ -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.
|
||||
@@ -164,8 +154,6 @@ pub struct WorktreeSnapshot {
|
||||
pub head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
/// Snapshot the worktree after a branch or tag switch.
|
||||
///
|
||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = gix::open(workdir)?;
|
||||
@@ -183,7 +171,6 @@ pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Check out `tree` into the worktree of `repo`
|
||||
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
@@ -229,7 +216,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R
|
||||
let files = gix::progress::Discard;
|
||||
let bytes = gix::progress::Discard;
|
||||
|
||||
// Check out the index into the worktree.
|
||||
gix_worktree_state::checkout(
|
||||
&mut index,
|
||||
workdir,
|
||||
@@ -240,7 +226,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R
|
||||
options,
|
||||
)?;
|
||||
|
||||
// Write the index to disk.
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
Ok(())
|
||||
@@ -258,7 +243,6 @@ fn move_head(
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
// Update the reference, creating a reflog entry.
|
||||
repo.edit_references_as(
|
||||
[RefEdit {
|
||||
change: Change::Update {
|
||||
@@ -293,7 +277,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the branch, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
@@ -301,7 +284,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the branch's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
@@ -320,7 +302,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the tag, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
@@ -328,7 +309,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the tag's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,7 +10,6 @@ pub struct Update {
|
||||
}
|
||||
|
||||
impl Update {
|
||||
/// Build an update from a received event.
|
||||
pub fn from_event(event: &Event) -> Self {
|
||||
let coordinate = event.tags.coordinates().nth(0);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ nostr-connect.workspace = true
|
||||
|
||||
bitcoin_hashes = "1"
|
||||
|
||||
gix.workspace = true
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
+198
-326
File diff suppressed because it is too large
Load Diff
@@ -9,27 +9,20 @@ 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;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How often the statuses are recomputed against the local refs.
|
||||
///
|
||||
/// A commit lands in a checkout long before the remote reconciliation cadence,
|
||||
/// so this fast pass surfaces ready-to-push and ready-to-contribute checkouts
|
||||
/// within a second or two. It reads the tracking refs only, no network.
|
||||
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How often a full pass refreshes the remotes while any repository panel is open.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Maximum checkouts considered per repository when computing statuses.
|
||||
const MAX_STATUS_CHECKOUTS: usize = 8;
|
||||
|
||||
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||
@@ -41,17 +34,12 @@ impl Global for GlobalCheckoutsStore {}
|
||||
/// Carries the git facts needed to suggest a pull request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutStatus {
|
||||
/// The checkout folder.
|
||||
pub path: PathBuf,
|
||||
/// The branch checked out. A detached checkout is idle and yields no status.
|
||||
pub branch: String,
|
||||
/// Commit the branch points at, for tip-based PR dedupe.
|
||||
pub head: String,
|
||||
/// What the branch is compared against.
|
||||
/// For ready-to-contribute statuses, the announced HEAD branch.
|
||||
/// The fallbacks are `main`, then the first local branch.
|
||||
/// For ready-to-push statuses, the remote-tracking ref.
|
||||
/// Unpushed commits are counted against it.
|
||||
///
|
||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||
pub base: String,
|
||||
@@ -69,11 +57,6 @@ struct Remembered {
|
||||
}
|
||||
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
///
|
||||
/// Readers (the sidebar rows, the repository panels) observe this store and
|
||||
/// derive what they display from their own snapshots, so publishing needs no
|
||||
/// fine-grained entities: the store notifies when a slice changed and each
|
||||
/// reader re-derives only what it shows.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository.
|
||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||
@@ -95,9 +78,9 @@ pub struct CheckoutsStore {
|
||||
///
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
/// A local status pass timer is pending.
|
||||
/// 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.
|
||||
///
|
||||
@@ -108,7 +91,6 @@ pub struct CheckoutsStore {
|
||||
}
|
||||
|
||||
impl CheckoutsStore {
|
||||
/// Retrieve the global checkouts store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalCheckoutsStore>().0.clone()
|
||||
}
|
||||
@@ -117,7 +99,6 @@ impl CheckoutsStore {
|
||||
cx.set_global(GlobalCheckoutsStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
@@ -148,6 +129,7 @@ impl CheckoutsStore {
|
||||
this.statuses.clear();
|
||||
this.push_statuses.clear();
|
||||
cx.notify();
|
||||
|
||||
this.refresh(cx);
|
||||
}
|
||||
}));
|
||||
@@ -170,13 +152,13 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
@@ -278,7 +260,6 @@ impl CheckoutsStore {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The number of unpushed commits for a repository.
|
||||
pub fn unpushed(&self, addr: &RepoAddr) -> usize {
|
||||
self.push_statuses
|
||||
.get(addr)
|
||||
@@ -287,13 +268,13 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Re-resolve the associations and the requested statuses.
|
||||
///
|
||||
/// Requests arriving while a pass runs fold into a follow-up.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
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))
|
||||
@@ -302,16 +283,10 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// One full resolve and apply cycle, the debounced entry point.
|
||||
///
|
||||
/// Re-resolves the associations from the settings, the scan and the
|
||||
/// announcements, then recomputes the requested statuses against freshly
|
||||
/// fetched remotes. Full passes run on every input change and on the
|
||||
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
|
||||
/// the fast local pass.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.debounce_pending = false;
|
||||
self.refresh.begin();
|
||||
|
||||
// Inputs snapshot, all cheap shared reads.
|
||||
let records = {
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.read(cx).settings().checkouts.records.clone()
|
||||
@@ -331,7 +306,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
|
||||
@@ -352,7 +327,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()
|
||||
@@ -431,10 +408,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Schedule the fast local status pass, unless one is already pending.
|
||||
///
|
||||
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
|
||||
/// the local refs — no network — so a new commit in a checkout surfaces in
|
||||
/// a second or two instead of at the next remote reconciliation.
|
||||
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
||||
if self.local_pending {
|
||||
return;
|
||||
@@ -452,10 +425,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// The fast local status pass.
|
||||
///
|
||||
/// Recomputes the statuses against the local refs; when the remote
|
||||
/// reconciliation cadence elapsed, it runs a full pass instead so pushes
|
||||
/// made elsewhere do not linger as `to push`.
|
||||
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
||||
// Nothing watched: the chain idles out until a new request restarts it.
|
||||
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
||||
@@ -463,7 +432,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;
|
||||
}
|
||||
@@ -490,10 +459,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Recompute the requested statuses against the tracking refs only.
|
||||
///
|
||||
/// The refs were last refreshed by a full pass. Comparing against them is
|
||||
/// enough to pick up new local commits, and skipping the network keeps
|
||||
/// this pass cheap enough to run every [`LOCAL_POLL`].
|
||||
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
||||
let associations = self.by_repo.clone();
|
||||
|
||||
@@ -525,7 +490,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;
|
||||
}
|
||||
|
||||
@@ -546,7 +511,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of a repository URL.
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
@@ -557,7 +521,6 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
Some((host, parsed.port(), path))
|
||||
}
|
||||
|
||||
/// Whether two repository URLs point at the same repository.
|
||||
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
match (url_identity(a), url_identity(b)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
@@ -565,7 +528,6 @@ fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the associations between local checkouts and announced repositories.
|
||||
fn resolve_associations<'a>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
@@ -608,7 +570,6 @@ fn resolve_associations<'a>(
|
||||
out
|
||||
}
|
||||
|
||||
/// The ready-to-contribute status of one checkout.
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
let branches = signed_git::worktree_branches(path).ok()?;
|
||||
|
||||
@@ -639,10 +600,6 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
||||
}
|
||||
|
||||
/// The `ready to push` status of one checkout of the user's own repository.
|
||||
///
|
||||
/// `fetch` refreshes the remote heads first, so a full pass sees pushes made
|
||||
/// elsewhere; the fast local pass skips it and compares against the tracking
|
||||
/// refs left by the last full pass, which is enough to detect local commits.
|
||||
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||
if signed_git::worktree_dirty(path) {
|
||||
return None;
|
||||
@@ -653,8 +610,6 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||
|
||||
if fetch {
|
||||
// Refresh the remote heads first.
|
||||
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||
}
|
||||
|
||||
@@ -682,10 +637,6 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||
}
|
||||
|
||||
/// Compute the requested statuses against the checkout paths of `associations`.
|
||||
///
|
||||
/// Shared by the full and the local pass. `fetch` refreshes the checkouts'
|
||||
/// remote heads first, so the full pass sees remote moves; the fast local
|
||||
/// pass reads the tracking refs only, which is enough to detect local commits.
|
||||
fn compute_statuses(
|
||||
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
||||
requested: &[(RepoAddr, Option<String>)],
|
||||
@@ -732,7 +683,6 @@ fn compute_statuses(
|
||||
(statuses, push_statuses)
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` already proposes the same change as `checkout`.
|
||||
pub fn pr_proposes_checkout(
|
||||
pr: &Event,
|
||||
open: bool,
|
||||
@@ -742,12 +692,14 @@ pub fn pr_proposes_checkout(
|
||||
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||
return false;
|
||||
}
|
||||
|
||||
let branch_matches = pr
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind() == "branch-name")
|
||||
.and_then(|t| t.content())
|
||||
.is_some_and(|name| name == checkout.branch);
|
||||
|
||||
// A renamed branch falls back to the proposed tip commit.
|
||||
let tip_matches = pr
|
||||
.tags
|
||||
@@ -755,6 +707,7 @@ pub fn pr_proposes_checkout(
|
||||
.find(|t| t.kind() == "c")
|
||||
.and_then(|t| t.content())
|
||||
.is_some_and(|tip| tip == checkout.head);
|
||||
|
||||
branch_matches || tip_matches
|
||||
}
|
||||
|
||||
@@ -860,40 +813,6 @@ mod tests {
|
||||
assert_eq!(resolved.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_matches_scanned_repos_by_origin_and_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let announcements = vec![
|
||||
announcement("repo", &["grasp://host/npub1x/repo"], None),
|
||||
announcement("family", &[], Some(euc)),
|
||||
];
|
||||
let repo = addr("repo");
|
||||
let family = addr("family");
|
||||
|
||||
let resolved = resolve_associations(
|
||||
&[],
|
||||
&[
|
||||
// Origin matches modulo scheme and the `.git` suffix.
|
||||
scanned("/clone", Some("https://host/npub1x/repo.git"), None),
|
||||
// Root commit matches the family EUC.
|
||||
scanned("/family-checkout", None, Some(euc)),
|
||||
// Neither matches anything.
|
||||
scanned("/unrelated", Some("https://elsewhere/x.git"), None),
|
||||
],
|
||||
&announcements,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved.get(&repo).expect("repo matches"),
|
||||
&vec![PathBuf::from("/clone")]
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.get(&family).expect("family matches"),
|
||||
&vec![PathBuf::from("/family-checkout")]
|
||||
);
|
||||
assert_eq!(resolved.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_deduplicates_paths_remembering_first() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -1048,61 +967,4 @@ mod tests {
|
||||
remote_run(&["commit", "-m", "remote work"]);
|
||||
assert_eq!(checkout_push_status(&checkout, true), None);
|
||||
}
|
||||
|
||||
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::GitPullRequest, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn status(branch: &str, head: &str) -> CheckoutStatus {
|
||||
CheckoutStatus {
|
||||
path: PathBuf::from("/checkout"),
|
||||
branch: branch.to_owned(),
|
||||
head: head.to_owned(),
|
||||
base: "main".to_owned(),
|
||||
ahead: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_proposes_checkout_matches_branch_or_tip() {
|
||||
let author = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
let tip = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
|
||||
// A matching `branch-name` covers the proposal.
|
||||
let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]);
|
||||
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Without a branch-name tag, the `c` tip still matches for a renamed branch.
|
||||
let pr = pr_event(
|
||||
author,
|
||||
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
|
||||
);
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Someone else's PR, a closed PR, a different branch and a missing tip.
|
||||
// They all leave the checkout uncovered.
|
||||
let pr = pr_event(author, &[&["branch-name", "feature"]]);
|
||||
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
|
||||
let other = pr_event(
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
&[&["branch-name", "feature"]],
|
||||
);
|
||||
assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status));
|
||||
let other_branch = pr_event(author, &[&["branch-name", "other"]]);
|
||||
assert!(!pr_proposes_checkout(
|
||||
&other_branch,
|
||||
true,
|
||||
other_branch.pubkey,
|
||||
&status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +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 {
|
||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
store
|
||||
}
|
||||
/// The on-disk path of the mirror of `addr`.
|
||||
pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf {
|
||||
git_cache().repo_path(addr)
|
||||
}
|
||||
|
||||
/// The app-wide clone cache.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Underlying clone cache.
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::backend::Backend;
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
/// Set once the stored state has been read for the current user.
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
@@ -21,12 +20,10 @@ impl Inbox {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Whether the stored state has been read for the current user.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.loaded
|
||||
}
|
||||
|
||||
/// Mark the events of one notification group read, then bound the id sets.
|
||||
pub fn mark_read(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
@@ -42,7 +39,7 @@ impl Inbox {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Archive one notification group. Archived events are always read too.
|
||||
/// Archived events are always read too.
|
||||
pub fn mark_archived(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
@@ -62,14 +59,12 @@ impl Inbox {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||
self.state.mark_all_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Load the stored state for current user.
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
@@ -101,7 +96,6 @@ impl Inbox {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Clear the state of the signed-out user.
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
@@ -110,11 +104,16 @@ impl Inbox {
|
||||
|
||||
/// Sign the state with a random key and store it locally.
|
||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
let backend = Backend::global(cx);
|
||||
let (me, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(me) = me else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let state = self.state.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
@@ -128,23 +127,15 @@ impl Inbox {
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the inbox home screen's lists for `me` from the local database.
|
||||
///
|
||||
/// Returns the notification groups, the user's own git activity and the number
|
||||
/// of non-archived groups with an unread event.
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error> {
|
||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||
let notifications = inbox::group(notification_events, me, state, &|id| {
|
||||
by_id.get(&id).cloned()
|
||||
});
|
||||
let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
|
||||
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||
|
||||
let mut activity = Vec::new();
|
||||
for event in client
|
||||
@@ -155,16 +146,17 @@ pub async fn query_inbox(
|
||||
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
||||
continue;
|
||||
}
|
||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||
activity.push(event);
|
||||
}
|
||||
|
||||
activity.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
||||
by_id.get(&id).cloned()
|
||||
});
|
||||
|
||||
Ok((notifications, activity, unread_count))
|
||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
|
||||
Ok((items, unread_count))
|
||||
}
|
||||
|
||||
/// `d` tag identifying the inbox state event of `me`.
|
||||
@@ -172,7 +164,6 @@ fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
/// Newest stored state for `me`.
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
@@ -229,7 +220,6 @@ async fn fetch_notifications(
|
||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||
|
||||
loop {
|
||||
// Keep only ids not walked yet, and remember them.
|
||||
pending.retain(|id| seen.insert(*id));
|
||||
|
||||
if pending.is_empty() {
|
||||
|
||||
@@ -2,6 +2,7 @@ mod backend;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod inbox;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
@@ -11,17 +12,19 @@ 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;
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
@@ -32,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)
|
||||
@@ -39,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);
|
||||
}
|
||||
|
||||
@@ -51,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
use signed_core::{Announcement, RepoAddr, repo_addr};
|
||||
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
||||
|
||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||
|
||||
impl Global for GlobalLocalReposStore {}
|
||||
|
||||
/// Store of the git repositories discovered under a set of scan paths.
|
||||
pub struct LocalReposStore {
|
||||
pub roots: Arc<Vec<PathBuf>>,
|
||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||
pub repos: Arc<Vec<LocalRepo>>,
|
||||
pub scanning: bool,
|
||||
scan_dirty: bool,
|
||||
}
|
||||
|
||||
impl LocalReposStore {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalLocalReposStore(entity));
|
||||
}
|
||||
|
||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
|
||||
log::warn!("local repos store dropped before initial scan could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
roots: Arc::new(roots),
|
||||
repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
scan_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
.iter()
|
||||
.filter(|repo| repo.path.as_path() != path)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||
if self.scanning {
|
||||
self.scan_dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.scanning = true;
|
||||
cx.notify();
|
||||
|
||||
let roots = self.roots.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let mut repos = Vec::new();
|
||||
for root in roots.iter() {
|
||||
repos.extend(find_git_repos(root));
|
||||
}
|
||||
repos.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
repos.dedup_by(|a, b| a.path == b.path);
|
||||
repos
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let repos = work.await;
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.repos = Arc::new(repos);
|
||||
this.scanning = false;
|
||||
cx.notify();
|
||||
|
||||
let dirty = this.scan_dirty;
|
||||
this.scan_dirty = false;
|
||||
dirty
|
||||
})?;
|
||||
|
||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// The NIP-34 coordinate a repository's detection resolved, when both the owner
|
||||
/// and the identifier were recovered.
|
||||
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
|
||||
let binding = repo.nip34.as_ref()?;
|
||||
let owner = binding.owner?;
|
||||
let identifier = binding.identifier.as_deref()?;
|
||||
|
||||
Some(repo_addr(owner, identifier))
|
||||
}
|
||||
|
||||
/// A scanned repository resolved against the known announcements.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedLocalRepo {
|
||||
pub path: PathBuf,
|
||||
/// `None` for a plain repository.
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
/// The known announcement this repository is bound to, when one matched.
|
||||
pub announcement: Option<Announcement>,
|
||||
}
|
||||
|
||||
impl ResolvedLocalRepo {
|
||||
/// The repository's directory name, or `Untitled` when the path has none.
|
||||
pub fn name(&self) -> SharedString {
|
||||
self.path
|
||||
.file_name()
|
||||
.map(|name| SharedString::from(name.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| SharedString::from("Untitled"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the scanned repositories against the known announcements.
|
||||
pub fn resolve_local_repos(
|
||||
repos: &[LocalRepo],
|
||||
known: &[Announcement],
|
||||
own: &[Announcement],
|
||||
) -> Vec<ResolvedLocalRepo> {
|
||||
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
|
||||
|
||||
repos
|
||||
.iter()
|
||||
.filter_map(|repo| {
|
||||
let addr = local_repo_addr(repo);
|
||||
|
||||
if let Some(addr) = &addr
|
||||
&& shown.contains(addr)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let announcement = addr
|
||||
.as_ref()
|
||||
.and_then(|addr| {
|
||||
known
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == *addr)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
Some(ResolvedLocalRepo {
|
||||
path: repo.path.clone(),
|
||||
nip34: repo.nip34.clone(),
|
||||
announcement,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr::prelude::*;
|
||||
use signed_git::{GraspSignals, Nip34Kind};
|
||||
|
||||
use super::*;
|
||||
|
||||
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const OTHER_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
|
||||
fn announcement(secret: &str, id: &str) -> Announcement {
|
||||
let keys = Keys::new(SecretKey::from_hex(secret).expect("secret"));
|
||||
let tag = Tag::parse(vec!["d", id]).expect("tag");
|
||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![tag])
|
||||
.finalize(&keys)
|
||||
.expect("signed");
|
||||
|
||||
Announcement::from_event(&event).expect("parsed")
|
||||
}
|
||||
|
||||
fn owner(secret: &str) -> PublicKey {
|
||||
Keys::new(SecretKey::from_hex(secret).expect("secret")).public_key()
|
||||
}
|
||||
|
||||
fn bound(secret: &str, id: &str) -> LocalRepo {
|
||||
let binding = Nip34Binding {
|
||||
kind: Nip34Kind::Initialized,
|
||||
signals: GraspSignals {
|
||||
nip34_json: true,
|
||||
..Default::default()
|
||||
},
|
||||
owner: Some(owner(secret)),
|
||||
identifier: Some(id.to_owned()),
|
||||
grasp_urls: Vec::new(),
|
||||
};
|
||||
|
||||
LocalRepo {
|
||||
path: PathBuf::from(id),
|
||||
nip34: Some(binding),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_own_announcement_is_dropped() {
|
||||
let own = announcement(KEY, "mine");
|
||||
let repo = bound(KEY, "mine");
|
||||
|
||||
let own = std::slice::from_ref(&own);
|
||||
assert!(resolve_local_repos(&[repo], own, own).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_owners_announcement_is_linked_and_kept() {
|
||||
let known = announcement(OTHER_KEY, "theirs");
|
||||
let repo = bound(OTHER_KEY, "theirs");
|
||||
|
||||
let resolved = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmatched_repository_keeps_its_binding() {
|
||||
let repo = bound(KEY, "unlisted");
|
||||
|
||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(resolved[0].announcement.is_none());
|
||||
assert_eq!(
|
||||
resolved[0].nip34.as_ref().map(|binding| binding.kind),
|
||||
Some(Nip34Kind::Initialized)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_repository_is_kept_without_a_binding() {
|
||||
let repo = LocalRepo {
|
||||
path: PathBuf::from("plain"),
|
||||
nip34: None,
|
||||
};
|
||||
|
||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(resolved[0].nip34.is_none());
|
||||
assert!(resolved[0].announcement.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_is_the_directory_name() {
|
||||
let repo = LocalRepo {
|
||||
path: PathBuf::from("/tmp/my-repo"),
|
||||
nip34: None,
|
||||
};
|
||||
|
||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||
|
||||
assert_eq!(resolved[0].name(), SharedString::from("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_without_a_directory_name_is_untitled() {
|
||||
let repo = LocalRepo {
|
||||
path: PathBuf::from("/"),
|
||||
nip34: None,
|
||||
};
|
||||
|
||||
let resolved = resolve_local_repos(&[repo], &[], &[]);
|
||||
|
||||
assert_eq!(resolved[0].name(), SharedString::from("Untitled"));
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,20 @@ use std::time::{Duration, Instant};
|
||||
use anyhow::Error;
|
||||
use flume::{Receiver, Sender};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
|
||||
/// How long to wait for more requests before firing a batched fetch.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// Max authors per profile request, keeping each filter within relay limits.
|
||||
const REQUEST_CHUNK: usize = 100;
|
||||
/// Recent profiles prefetched at startup and read back from the cache.
|
||||
const WARM_LIMIT: usize = 500;
|
||||
|
||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
@@ -50,10 +56,9 @@ impl Profile {
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key, 4))
|
||||
SharedString::from(shorten_pubkey(self.public_key))
|
||||
}
|
||||
|
||||
/// Avatar URL, if set.
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.picture
|
||||
@@ -63,9 +68,6 @@ impl Profile {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait for more requests before firing a batched sync.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Global profile cache.
|
||||
///
|
||||
/// Profiles are fetched in batches and kept as plain data.
|
||||
@@ -83,7 +85,6 @@ struct GlobalProfileStore(Entity<ProfileStore>);
|
||||
impl Global for GlobalProfileStore {}
|
||||
|
||||
impl ProfileStore {
|
||||
/// Retrieve the global profile store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalProfileStore>().0.clone()
|
||||
}
|
||||
@@ -94,40 +95,26 @@ impl ProfileStore {
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let entity = cx.entity().downgrade();
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
for update in updates
|
||||
.iter()
|
||||
.filter(|update| update.kind == Kind::Metadata)
|
||||
{
|
||||
this.apply_author(update.author, cx);
|
||||
}
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||
this.apply_authors(authors.clone(), cx);
|
||||
}
|
||||
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
this.profiles
|
||||
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
||||
let client = backend.read(cx).client();
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |_this, cx| {
|
||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
||||
log::error!("Failed to handle requests: {e}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
||||
log::warn!("profile store dropped before initial load could run: {error}");
|
||||
}
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -157,17 +144,14 @@ impl ProfileStore {
|
||||
Profile::new(public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Load recently seen profiles from the local database.
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread.
|
||||
// Only plain profiles cross back.
|
||||
let profiles: Vec<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
@@ -179,7 +163,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -189,54 +173,13 @@ impl ProfileStore {
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of an author from the local database.
|
||||
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread.
|
||||
// Only the profile crosses back.
|
||||
let profile = events
|
||||
.into_iter()
|
||||
.max_by_key(|e| e.created_at)
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Profile::new(event.pubkey, metadata)
|
||||
});
|
||||
|
||||
Ok::<_, Error>(profile)
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profile = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some(profile) = profile {
|
||||
this.profiles.insert(profile.public_key(), profile);
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of every requested author from the local database.
|
||||
///
|
||||
/// Used after a sync, which produces no NostrUpdate events.
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
|
||||
/// Re-read the latest metadata of `authors` from the local database in one query.
|
||||
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||
if authors.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -247,9 +190,9 @@ impl ProfileStore {
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Pick the latest metadata per author off the main thread.
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((ts, _)) if *ts >= event.created_at => {}
|
||||
@@ -273,7 +216,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -283,14 +226,18 @@ impl ProfileStore {
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||
///
|
||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
||||
/// Re-read the latest metadata of every requested author from the local database.
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
self.apply_authors(authors, cx);
|
||||
}
|
||||
|
||||
/// Fetch metadata for requested authors in batches, debounced to collect requests.
|
||||
async fn handle_requests(
|
||||
this: WeakEntity<ProfileStore>,
|
||||
client: &Client,
|
||||
@@ -300,7 +247,6 @@ impl ProfileStore {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
// Wait for the first request of a batch.
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
@@ -308,18 +254,22 @@ impl ProfileStore {
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// Collect everything that arrives within the debounce window.
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
@@ -329,18 +279,21 @@ impl ProfileStore {
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||
|
||||
// Negentropy-sync with the bootstrap relays.
|
||||
// Synced events are written to the database directly, no NostrUpdate.
|
||||
// Re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||
let opts = SyncOptions::default();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(chunk.iter().copied());
|
||||
|
||||
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
||||
log::warn!("profile fetch failed: {e}");
|
||||
}
|
||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||
}
|
||||
|
||||
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,33 @@
|
||||
/// Refresh coalescing shared by the event stores.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RefreshGate {
|
||||
/// A run is in flight.
|
||||
running: bool,
|
||||
/// A request arrived while a run was in flight.
|
||||
dirty: bool,
|
||||
/// The debounce timer is pending.
|
||||
debouncing: bool,
|
||||
}
|
||||
|
||||
/// What a refresh request decided.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshRequest {
|
||||
/// No run or timer covers the request, start the debounce timer.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl RefreshGate {
|
||||
/// Whether a run is in flight.
|
||||
pub fn running(&self) -> bool {
|
||||
self.running
|
||||
}
|
||||
|
||||
/// Whether the debounce timer is pending.
|
||||
pub fn debouncing(&self) -> bool {
|
||||
self.debouncing
|
||||
}
|
||||
|
||||
/// A new refresh request arrived.
|
||||
///
|
||||
/// Folded into a follow-up run while one is in flight, dropped while the
|
||||
/// debounce timer is pending, otherwise starts the timer.
|
||||
pub fn request(&mut self) -> RefreshRequest {
|
||||
if self.running {
|
||||
self.dirty = true;
|
||||
RefreshRequest::Fold
|
||||
} else if self.debouncing {
|
||||
RefreshRequest::Fold
|
||||
} else {
|
||||
self.debouncing = true;
|
||||
RefreshRequest::Schedule
|
||||
}
|
||||
}
|
||||
|
||||
/// The debounce timer fired and the run starts now.
|
||||
pub fn begin(&mut self) {
|
||||
self.debouncing = false;
|
||||
self.running = true;
|
||||
}
|
||||
|
||||
@@ -62,3 +42,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());
|
||||
}
|
||||
}
|
||||
|
||||
+429
-282
File diff suppressed because it is too large
Load Diff
@@ -1,127 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
use signed_git::find_git_repos;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
|
||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||
|
||||
impl Global for GlobalLocalReposStore {}
|
||||
|
||||
/// Store of the git repositories discovered under a set of scan paths.
|
||||
pub struct LocalReposStore {
|
||||
/// The directories being scanned.
|
||||
pub roots: Arc<Vec<PathBuf>>,
|
||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||
pub repos: Arc<Vec<PathBuf>>,
|
||||
/// A scan is currently running.
|
||||
pub scanning: bool,
|
||||
/// A scan was requested while one was already running.
|
||||
scan_dirty: bool,
|
||||
}
|
||||
|
||||
impl LocalReposStore {
|
||||
/// Retrieve the global local-repositories store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalLocalReposStore(entity));
|
||||
}
|
||||
|
||||
/// Create a store scanning `roots` right away.
|
||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
|
||||
log::warn!("local repos store dropped before initial scan could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
roots: Arc::new(roots),
|
||||
repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
scan_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
.iter()
|
||||
.filter(|repo| repo.as_path() != path)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Re-run the scan.
|
||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||
if self.scanning {
|
||||
self.scan_dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.scanning = true;
|
||||
cx.notify();
|
||||
|
||||
let roots = self.roots.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let mut repos = Vec::new();
|
||||
for root in roots.iter() {
|
||||
repos.extend(find_git_repos(root));
|
||||
}
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
repos
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let repos = work.await;
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.repos = Arc::new(repos);
|
||||
this.scanning = false;
|
||||
cx.notify();
|
||||
|
||||
let dirty = this.scan_dirty;
|
||||
this.scan_dirty = false;
|
||||
dirty
|
||||
})?;
|
||||
|
||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
///
|
||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||
|
||||
@@ -154,19 +42,18 @@ pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository.
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues, pull requests and commits per repository.
|
||||
///
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
/// Own repositories whose state events were fetched from their announced relays.
|
||||
state_synced_repos: HashSet<RepoAddr>,
|
||||
refresh: RefreshGate,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global repository list store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
@@ -175,39 +62,17 @@ impl RepoListStore {
|
||||
cx.set_global(GlobalRepoListStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store listing all announcements.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||
// Deletions may target anything we list, always refresh.
|
||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||
true
|
||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||
// Activity events are addressed to repos via `a` tags.
|
||||
// Their author is not the repo owner, always refresh.
|
||||
true
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
let is_repo_state = update.kind == Kind::RepoState;
|
||||
is_announcement || is_repo_state
|
||||
}
|
||||
}),
|
||||
BackendEvent::Published(event) => {
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||
|
||||
// Locally published deletions are already in the local database.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
announcement || deletion
|
||||
BackendEvent::SignerChanged => {
|
||||
this.state_synced_repos.clear();
|
||||
true
|
||||
}
|
||||
// Only a completed sync refreshes the list.
|
||||
// Progress ticks would re-scan the whole database several times
|
||||
// per sync to reveal entries incrementally.
|
||||
BackendEvent::RepoUpdates(_) => true,
|
||||
BackendEvent::Synced => true,
|
||||
_ => false,
|
||||
};
|
||||
@@ -217,23 +82,19 @@ impl RepoListStore {
|
||||
}
|
||||
});
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
let result = weak.update(cx, |this, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.subscribe_remote(cx);
|
||||
// Query the local database right away.
|
||||
// The list never waits for the relay syncs started above to finish.
|
||||
this.refresh_initial(cx);
|
||||
});
|
||||
if let Err(error) = result {
|
||||
log::warn!("repo list store dropped before bootstrap could run: {error}");
|
||||
}
|
||||
this.refresh(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
state_synced_repos: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
@@ -248,44 +109,58 @@ impl RepoListStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
backend.sync_bootstraps(
|
||||
vec![
|
||||
filters::all_announcements(),
|
||||
filters::all_states(),
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
filters::deletions(),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load.
|
||||
///
|
||||
/// Query the local database immediately, no debounce.
|
||||
/// Stored announcements appear as soon as the app opens.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
/// Fetch the state events of the user's own repositories.
|
||||
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let Some(me) = backend.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pending: Vec<(RepoAddr, Vec<RelayUrl>)> = self
|
||||
.announcements
|
||||
.iter()
|
||||
.filter(|announcement| announcement.owner == me && !announcement.relays.is_empty())
|
||||
.map(|announcement| (announcement.addr(), announcement.relays.clone()))
|
||||
.filter(|(addr, _)| !self.state_synced_repos.contains(addr))
|
||||
.collect();
|
||||
|
||||
for (addr, relays) in pending {
|
||||
self.state_synced_repos.insert(addr.clone());
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
|
||||
});
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
///
|
||||
/// Runs immediately. The backend pump already batches the relay events that
|
||||
/// trigger a refresh, so no per-store debounce is needed.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
})
|
||||
.detach();
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
@@ -325,9 +200,6 @@ impl RepoListStore {
|
||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||
|
||||
// Last activity per repository.
|
||||
// State updates count, and all NIP-34 activity events.
|
||||
// The activity events are patches, PRs, issues and statuses.
|
||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
@@ -353,6 +225,7 @@ impl RepoListStore {
|
||||
let activity_filter = Filter::new()
|
||||
.kinds(filters::ACTIVITY_KINDS)
|
||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||
|
||||
for event in client.database().query(activity_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
@@ -361,8 +234,6 @@ impl RepoListStore {
|
||||
if addr.kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
@@ -375,13 +246,12 @@ impl RepoListStore {
|
||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
|
||||
for event in client.database().query(count_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||
{
|
||||
continue;
|
||||
@@ -414,6 +284,7 @@ impl RepoListStore {
|
||||
this.announcements = Arc::new(announcements);
|
||||
this.last_activity = Arc::new(last_activity);
|
||||
this.counts = Arc::new(counts);
|
||||
this.sync_own_repo_states(cx);
|
||||
cx.notify();
|
||||
|
||||
this.refresh.finish()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
|
||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||
|
||||
use crate::pixel_avatar::{PixelAvatar, side_length};
|
||||
|
||||
/// A user avatar built on the unstyled [`gpui_base::Avatar`].
|
||||
///
|
||||
/// It shows the user's picture when one is set, and falls back to the
|
||||
/// deterministic pixel avatar seeded from the name otherwise.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
name: SharedString,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Create an avatar for `name`.
|
||||
///
|
||||
/// The name seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Avatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Avatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let fallback = AvatarFallback::new()
|
||||
.size_full()
|
||||
.child(PixelAvatar::new(self.name.clone()).size_full());
|
||||
|
||||
BaseAvatar::new()
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(cx.theme().radius)
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().secondary)
|
||||
.when_some(self.picture, |this, url| {
|
||||
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
|
||||
})
|
||||
.fallback(fallback)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,18 @@ use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// A split dropdown button built on `gpui_base::Popover`.
|
||||
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
||||
/// The action and the caret are caller-supplied elements, so the look stays in the app.
|
||||
/// This component only owns the popover wiring.
|
||||
/// An action element next to a caret that opens a [`PopupMenu`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
caret: Option<CaretBuilder>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
||||
|
||||
impl DropdownButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
@@ -32,7 +28,6 @@ impl DropdownButton {
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
caret: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
@@ -54,14 +49,6 @@ impl DropdownButton {
|
||||
self.menu = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
|
||||
/// Which corner of the caret the menu anchors to.
|
||||
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
|
||||
#[allow(dead_code)] // API knob, current call sites use the default anchor.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for DropdownButton {
|
||||
@@ -91,28 +78,24 @@ impl RenderOnce for DropdownButton {
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
let caret = self.caret.unwrap_or_else(|| {
|
||||
let id = popover_id.clone();
|
||||
Box::new(move |is_open, _, cx| {
|
||||
let caret = default_caret(id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
let caret_id = popover_id.clone();
|
||||
this.child(
|
||||
Popover::new(popover_id)
|
||||
.anchor(anchor)
|
||||
// The menu dismisses itself on outside click or Escape.
|
||||
// The subscription below closes the popover along with it.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.trigger_with(move |is_open, _, cx| {
|
||||
let caret = default_caret(caret_id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
@@ -162,24 +145,3 @@ fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::div;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dropdown_button_builder_state() {
|
||||
let button = DropdownButton::new("issues")
|
||||
.action(div())
|
||||
.anchor(Anchor::BottomLeft)
|
||||
.dropdown_menu(|menu, _, _| menu);
|
||||
|
||||
assert!(button.action.is_some());
|
||||
// The caret is `None` until render, which falls back to the default.
|
||||
assert!(button.caret.is_none());
|
||||
assert!(button.menu.is_some());
|
||||
assert_eq!(button.anchor, Anchor::BottomLeft);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
mod avatar;
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
mod placeholder;
|
||||
mod ref_selector;
|
||||
mod segment_button;
|
||||
mod setting;
|
||||
mod status_badge;
|
||||
mod title_bar;
|
||||
mod tree_row;
|
||||
mod user_avatar;
|
||||
|
||||
pub mod copy_row;
|
||||
pub mod util;
|
||||
|
||||
pub use avatar::Avatar;
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
pub use ref_selector::ref_selector_trigger;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
pub use setting::{SelectOption, setting_block, setting_row};
|
||||
pub use status_badge::status_badge;
|
||||
pub use title_bar::title_bar_drag_handlers;
|
||||
pub use tree_row::tree_row;
|
||||
pub use user_avatar::UserAvatar;
|
||||
pub use util::middle_truncate;
|
||||
|
||||
@@ -35,7 +35,6 @@ impl NavItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// A trailing element rendered at the right edge of the row
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
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;
|
||||
/// Probability that a cell in the left half is filled.
|
||||
/// Empty cells kept between the pattern and the avatar edge, so the art
|
||||
/// gathers in the center instead of filling the whole avatar.
|
||||
const MARGIN: usize = 1;
|
||||
/// Probability that a cell in the left half of the pattern area is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// 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::Small,
|
||||
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 +51,17 @@ impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let pattern = pattern(self.seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
let hue = self.seed as f32 / u64::MAX as f32;
|
||||
let main = theme.blue.hue(hue);
|
||||
|
||||
let shade = if theme.is_dark() {
|
||||
main.lightness((main.l * 1.6).min(0.95))
|
||||
} else {
|
||||
main.lightness((main.l * 0.45).max(0.18))
|
||||
};
|
||||
|
||||
let mut cells = Vec::new();
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
let value = pattern[row * GRID_SIZE + col];
|
||||
@@ -76,28 +80,42 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(AVATAR_SIZE)
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(theme.radius)
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
.children(cells)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`, shared with [`Avatar`].
|
||||
pub(crate) fn side_length(size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => px(16.),
|
||||
Size::Small => px(24.),
|
||||
Size::Medium => px(48.),
|
||||
Size::Large => px(80.),
|
||||
Size::Size(size) => size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the 8×8 cell pattern for `seed`.
|
||||
/// Cells are `0` for empty, `1` for main color and `2` for accent shade.
|
||||
/// The right half mirrors the left half.
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
let mut filled = 0usize;
|
||||
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE / 2 {
|
||||
// Only the inner rows and the inner left half are candidates; mirroring
|
||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||
|
||||
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||
for col in MARGIN..GRID_SIZE / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
@@ -106,18 +124,19 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse rolls can come out nearly empty.
|
||||
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
|
||||
if filled < MIN_FILLED {
|
||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
||||
let start = (rng.next() % half as u64) as usize;
|
||||
for offset in 0..half {
|
||||
let total = art_rows * art_columns;
|
||||
let start = (rng.next() % total as u64) as usize;
|
||||
|
||||
for offset in 0..total {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
let ix = (start + offset) % half;
|
||||
let row = ix / (GRID_SIZE / 2);
|
||||
let col = ix % (GRID_SIZE / 2);
|
||||
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * GRID_SIZE + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
@@ -175,9 +194,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_is_mirror_symmetric() {
|
||||
fn pattern_properties() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pattern(seed);
|
||||
assert!(
|
||||
count_filled(&pattern) >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
assert_eq!(
|
||||
@@ -188,31 +211,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_has_minimum_fill() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pattern(seed);
|
||||
assert!(
|
||||
count_filled(&pattern) >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_is_deterministic() {
|
||||
for seed in [0, 1, 42, u64::MAX] {
|
||||
assert_eq!(pattern(seed), pattern(seed));
|
||||
}
|
||||
assert_ne!(pattern(42), pattern(43));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fnv1a_is_stable_and_distinct() {
|
||||
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
|
||||
assert_eq!(fnv1a(b"repo"), fnv1a(b"repo"));
|
||||
assert_ne!(fnv1a(b"repo:a"), fnv1a(b"repo:b"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
|
||||
/// The kind icon, the selection or placeholder, and the caret. `Combobox`
|
||||
/// replaces its default trigger entirely, the only way to show an icon inside it.
|
||||
pub fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -21,12 +21,10 @@ impl SelectOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value of this option.
|
||||
pub fn value(&self) -> &SharedString {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// The display label of this option.
|
||||
pub fn label(&self) -> &SharedString {
|
||||
&self.label
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ where
|
||||
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
// Folders expand/collapse via the tree itself.
|
||||
if is_folder {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt};
|
||||
|
||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
||||
/// It shows the user's picture or falls back to name initials.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UserAvatar {
|
||||
name: SharedString,
|
||||
picture: Option<SharedString>,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl UserAvatar {
|
||||
/// Create an avatar for `name`.
|
||||
/// The name seeds the initials fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
picture: None,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for UserAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for UserAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
Avatar::new()
|
||||
.name(self.name)
|
||||
.when_some(self.picture, |this, url| this.src(url))
|
||||
.rounded(cx.theme().radius)
|
||||
.refine_style(&self.style)
|
||||
.small()
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ mod tests {
|
||||
),
|
||||
"30617:a008...3564d:ngit"
|
||||
);
|
||||
// Too short to save space with the ellipsis, left alone.
|
||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,6 @@ mod tests {
|
||||
assert_eq!(relative_time(now - 3 * 86_400), "3d ago");
|
||||
assert_eq!(relative_time(now - 60 * 86_400), "2mo ago");
|
||||
assert_eq!(relative_time(now - 800 * 86_400), "2y ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_future_timestamps() {
|
||||
assert_eq!(relative_time(Timestamp::now() + 600), "just now");
|
||||
assert_eq!(relative_time(now + 600), "just now");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ utils = { path = "../utils" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-fps.workspace = true
|
||||
gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
|
||||
+193
-36
@@ -13,34 +13,27 @@ use gpui_component::resizable::{resizable_panel, v_resizable};
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
|
||||
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
use crate::views::tree::{build_tree_items, tree_items};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
/// Tree and per-file diff body, shared by the commit diff and compare views.
|
||||
pub struct DiffPane {
|
||||
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
||||
diff: Option<CommitDiff>,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
rows: Vec<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the diff rows.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
}
|
||||
|
||||
@@ -56,12 +49,10 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// The loaded diff, for stats and badges in the host's header.
|
||||
pub fn diff(&self) -> Option<&CommitDiff> {
|
||||
self.diff.as_ref()
|
||||
}
|
||||
|
||||
/// Replace the diff and rebuild the tree and the selected file's rows.
|
||||
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
@@ -86,9 +77,6 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the diff, e.g. when the compared branches changed.
|
||||
///
|
||||
/// Clears the tree, the selection and the diff rows.
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
self.diff = None;
|
||||
self.selected_file = None;
|
||||
@@ -99,14 +87,12 @@ impl DiffPane {
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the diff of the file at `path`, selected in the tree.
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Rebuild the virtual list state for `path` and scroll back to the top.
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
@@ -119,7 +105,6 @@ impl DiffPane {
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
/// One row of the changed-files tree, icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
@@ -136,7 +121,6 @@ impl DiffPane {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the changed-files tree.
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
@@ -166,7 +150,6 @@ impl DiffPane {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right column, header of the selected file plus its diff.
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("No changes", cx);
|
||||
@@ -184,7 +167,6 @@ impl DiffPane {
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
/// The diff of one file, with a header showing status and stats.
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
DiffStatus::Added => "A",
|
||||
@@ -311,24 +293,22 @@ impl Render for DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail panel showing the diff of one commit.
|
||||
pub struct CommitDiffView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Local clone the commit lives in.
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
/// Display name of the repository the commit belongs to.
|
||||
repo_name: SharedString,
|
||||
/// The commit being shown in the header and tab title.
|
||||
commit: FileCommit,
|
||||
/// The diff is being computed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||
pane: Entity<DiffPane>,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
impl CommitDiffView {
|
||||
pub fn new(
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
repo_name: SharedString,
|
||||
commit_id: String,
|
||||
@@ -344,6 +324,7 @@ impl CommitDiffView {
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
worktree,
|
||||
repo_name,
|
||||
commit: FileCommit {
|
||||
@@ -356,10 +337,10 @@ impl CommitDiffView {
|
||||
loading: true,
|
||||
error: None,
|
||||
pane,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the commit diff and the full commit metadata.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -404,10 +385,9 @@ impl CommitDiffView {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Header with the commit id, summary, author/time and overall change stats.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
@@ -492,11 +472,11 @@ impl BasePanel for CommitDiffView {
|
||||
}
|
||||
|
||||
impl Panel for CommitDiffView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().text_sm().child(SharedString::from(format!(
|
||||
"{}/{}",
|
||||
self.repo_name, self.commit.id
|
||||
)))
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,3 +515,180 @@ impl Render for CommitDiffView {
|
||||
.child(resizable_panel().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
const GUTTER_WIDTH: f32 = 44.;
|
||||
const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
Line {
|
||||
hunk: usize,
|
||||
line: usize,
|
||||
},
|
||||
}
|
||||
|
||||
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(crate) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -2,8 +2,7 @@ use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// Progress of an async dialog action: a busy flag disabling the form,
|
||||
/// and an error line shown under it.
|
||||
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
@@ -11,13 +10,13 @@ pub struct DialogProgress {
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
/// An action started, disable the form and clear the previous error.
|
||||
/// Marks an action as started, disabling the form and clearing the previous error.
|
||||
pub fn begin(&mut self) {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// An action failed, re-enable the form and surface `message`.
|
||||
/// Marks an action as failed, enabling the form and showing `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
self.error = Some(message.into());
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::Avatar;
|
||||
use utils::relative_time;
|
||||
|
||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
+233
-387
@@ -1,9 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
@@ -12,47 +10,29 @@ use gpui::{
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, Kind, Timestamp};
|
||||
use signed_core::{
|
||||
COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
|
||||
};
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||
};
|
||||
use signed_ui::{CountBadge, UserAvatar};
|
||||
use signed_ui::{Avatar, CountBadge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::{RepoItem, open_repo_item, open_repo_panel};
|
||||
use super::{RepoItem, open_repo_item, panel_avatar, tab_title};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Extra list rows measured above and below the visible area.
|
||||
const LIST_OVERDRAW: Pixels = px(400.);
|
||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||
|
||||
/// A repository's slice of the inbox: its notification groups and own activity.
|
||||
struct InboxSection {
|
||||
/// Repository the section groups, `None` for items without one.
|
||||
/// `None` for items without a repository.
|
||||
address: Option<RepoAddr>,
|
||||
/// Number of notification groups with an unread event.
|
||||
unread: usize,
|
||||
/// Notification and activity rows, newest first.
|
||||
entries: Vec<InboxEntry>,
|
||||
/// Indices into the threads, newest activity first.
|
||||
entries: Vec<usize>,
|
||||
/// Timestamp of the newest entry, used to order the sections.
|
||||
latest: Timestamp,
|
||||
}
|
||||
|
||||
/// One row inside a repository section, as an index into the inbox's own lists.
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxEntry {
|
||||
/// Index into the notification groups.
|
||||
Notification(usize),
|
||||
/// Index into the user's own activity.
|
||||
Activity(usize),
|
||||
}
|
||||
|
||||
/// One row of the flattened inbox: a repository header, one of its entries, or
|
||||
/// the empty state of a repository without any.
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxRow {
|
||||
Repo(usize),
|
||||
@@ -63,19 +43,13 @@ enum InboxRow {
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Notifications grouped by thread root, newest activity first.
|
||||
notifications: Arc<Vec<InboxItem>>,
|
||||
/// The user's own recent git activity, newest first.
|
||||
activity: Arc<Vec<Event>>,
|
||||
/// The notification and activity lists grouped by repository, newest first.
|
||||
/// One row per thread, merging notifications and own activity, newest first.
|
||||
threads: Arc<Vec<InboxItem>>,
|
||||
sections: Arc<Vec<InboxSection>>,
|
||||
/// The flattened repository headers and rows of the list.
|
||||
rows: Arc<Vec<InboxRow>>,
|
||||
/// Number of non-archived groups with an unread event.
|
||||
unread_count: usize,
|
||||
/// Copy of the global read state the current lists were derived with.
|
||||
state: InboxReadState,
|
||||
/// Set once the global state has been read for the current user.
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
@@ -118,8 +92,7 @@ impl InboxView {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
notifications: Arc::new(Vec::new()),
|
||||
activity: Arc::new(Vec::new()),
|
||||
threads: Arc::new(Vec::new()),
|
||||
sections: Arc::new(Vec::new()),
|
||||
rows: Arc::new(Vec::new()),
|
||||
unread_count: 0,
|
||||
@@ -132,29 +105,35 @@ impl InboxView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
let all = self.all_notification_events();
|
||||
let inbox = Backend::global(cx).read(cx).inbox();
|
||||
|
||||
let all: Vec<Event> = self
|
||||
.threads
|
||||
.iter()
|
||||
.flat_map(|item| item.events.iter().cloned())
|
||||
.collect();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
|
||||
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
|
||||
}
|
||||
|
||||
/// Re-derive from the global state when it is loaded or changes.
|
||||
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
|
||||
let inbox = Backend::global(cx).read(cx).inbox();
|
||||
let backend = Backend::global(cx);
|
||||
let inbox = backend.read(cx).inbox();
|
||||
|
||||
let (loaded, state) = {
|
||||
let inbox = inbox.read(cx);
|
||||
(inbox.is_loaded(), inbox.state().clone())
|
||||
};
|
||||
|
||||
if !loaded {
|
||||
let was_present = self.state_loaded
|
||||
|| !self.notifications.is_empty()
|
||||
|| !self.activity.is_empty()
|
||||
|| !self.sections.is_empty();
|
||||
let was_present =
|
||||
self.state_loaded || !self.threads.is_empty() || !self.sections.is_empty();
|
||||
self.clear();
|
||||
if was_present {
|
||||
cx.notify();
|
||||
@@ -176,31 +155,15 @@ impl InboxView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a backend event that can change the derived sections.
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
let relevant = updates.iter().any(|update| {
|
||||
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
||||
let is_comment = update.kind == Kind::Comment;
|
||||
let is_event_deletion = update.kind == Kind::EventDeletion;
|
||||
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
||||
|
||||
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
||||
});
|
||||
|
||||
if relevant {
|
||||
self.refresh(cx);
|
||||
}
|
||||
}
|
||||
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
||||
BackendEvent::RepoUpdates(_) => self.refresh(cx),
|
||||
BackendEvent::Synced => self.refresh(cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot initial load, no debounce.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
@@ -208,7 +171,6 @@ impl InboxView {
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.state_loaded {
|
||||
return;
|
||||
@@ -218,29 +180,29 @@ 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);
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(me) = backend.read(cx).current_user() else {
|
||||
let (me, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(me) = me else {
|
||||
self.refresh.abort();
|
||||
return;
|
||||
};
|
||||
|
||||
let client = backend.read(cx).client();
|
||||
let state = self.state.clone();
|
||||
|
||||
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (notifications, activity, unread_count) = match work.await {
|
||||
let (threads, unread_count) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
log::warn!("inbox refresh failed: {error}");
|
||||
@@ -254,8 +216,7 @@ impl InboxView {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.notifications = Arc::new(notifications);
|
||||
this.activity = Arc::new(activity);
|
||||
this.threads = Arc::new(threads);
|
||||
this.unread_count = unread_count;
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
@@ -271,24 +232,22 @@ impl InboxView {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from the current state.
|
||||
fn regroup(&mut self, cx: &mut Context<Self>) {
|
||||
let mut items = (*self.notifications).clone();
|
||||
let mut items = (*self.threads).clone();
|
||||
|
||||
for item in items.iter_mut() {
|
||||
item.apply_state(&self.state);
|
||||
}
|
||||
|
||||
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
self.notifications = Arc::new(items);
|
||||
self.threads = Arc::new(items);
|
||||
self.rebuild(cx);
|
||||
}
|
||||
|
||||
/// Regroup the current lists by repository and flatten them into rows.
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let mut sections = group_sections(&self.notifications, &self.activity);
|
||||
let mut sections = self.group_sections();
|
||||
|
||||
if let Some(me) = backend.read(cx).current_user() {
|
||||
for announcement in repo_list.read(cx).announcements_of(&me) {
|
||||
@@ -310,15 +269,72 @@ impl InboxView {
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
|
||||
let rows = flatten_rows(§ions);
|
||||
let rows = self.flatten_rows(§ions);
|
||||
self.sections = Arc::new(sections);
|
||||
self.rows = Arc::new(rows);
|
||||
}
|
||||
|
||||
/// Forget everything derived for the current user.
|
||||
fn group_sections(&self) -> Vec<InboxSection> {
|
||||
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
|
||||
|
||||
for (ix, item) in self.threads.iter().enumerate() {
|
||||
if item.archived {
|
||||
continue;
|
||||
}
|
||||
|
||||
let address = item.address.clone();
|
||||
let section = by_repo
|
||||
.entry(address.clone())
|
||||
.or_insert_with(move || InboxSection {
|
||||
address,
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
|
||||
if item.is_unread() {
|
||||
section.unread += 1;
|
||||
}
|
||||
|
||||
section.latest = section.latest.max(item.latest_activity());
|
||||
section.entries.push(ix);
|
||||
}
|
||||
|
||||
let mut sections: Vec<InboxSection> = by_repo.into_values().collect();
|
||||
|
||||
for section in &mut sections {
|
||||
section.entries.sort_by(|a, b| {
|
||||
self.threads[*b]
|
||||
.latest_activity()
|
||||
.cmp(&self.threads[*a].latest_activity())
|
||||
});
|
||||
}
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
sections
|
||||
}
|
||||
|
||||
fn flatten_rows(&self, sections: &[InboxSection]) -> Vec<InboxRow> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (section_ix, section) in sections.iter().enumerate() {
|
||||
rows.push(InboxRow::Repo(section_ix));
|
||||
|
||||
if section.entries.is_empty() {
|
||||
rows.push(InboxRow::Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.extend(
|
||||
(0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)),
|
||||
);
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.notifications = Arc::new(Vec::new());
|
||||
self.activity = Arc::new(Vec::new());
|
||||
self.threads = Arc::new(Vec::new());
|
||||
self.sections = Arc::new(Vec::new());
|
||||
self.rows = Arc::new(Vec::new());
|
||||
self.unread_count = 0;
|
||||
@@ -328,15 +344,29 @@ impl InboxView {
|
||||
self.refresh = RefreshGate::default();
|
||||
}
|
||||
|
||||
/// Every event in every group, archived groups included.
|
||||
fn all_notification_events(&self) -> Vec<Event> {
|
||||
self.notifications
|
||||
.iter()
|
||||
.flat_map(|item| item.events.iter().cloned())
|
||||
.collect()
|
||||
fn open(
|
||||
&self,
|
||||
root: EventId,
|
||||
kind: Option<Kind>,
|
||||
address: Option<RepoAddr>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(address) = address else {
|
||||
return;
|
||||
};
|
||||
|
||||
let item = match kind {
|
||||
Some(Kind::GitIssue) => RepoItem::Issue(root),
|
||||
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
|
||||
Some(Kind::GitPatch) => RepoItem::Patch,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
open_repo_item(&self.dock_area, &address, None, item, window, cx);
|
||||
}
|
||||
|
||||
fn render_entry(&self, ix: usize, cx: &App) -> AnyElement {
|
||||
fn render_entry(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||
let Some(row) = self.rows.get(ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
@@ -354,136 +384,30 @@ impl InboxView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let Some(entry) = section.entries.get(entry_ix) else {
|
||||
let Some(&thread_ix) = section.entries.get(entry_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
match *entry {
|
||||
InboxEntry::Notification(item_ix) => {
|
||||
let Some(item) = self.notifications.get(item_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let Some(item) = self.threads.get(thread_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let root = item.root;
|
||||
let kind = item.root_kind;
|
||||
let address = section.address.clone();
|
||||
let dock_area = self.dock_area.clone();
|
||||
let root = item.root;
|
||||
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();
|
||||
|
||||
notification_row("inbox-row", ix, item, cx)
|
||||
.on_click(move |_, window, cx| {
|
||||
open_item(&dock_area, root, kind, address.clone(), window, cx);
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
InboxEntry::Activity(event_ix) => {
|
||||
let Some(event) = self.activity.get(event_ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
activity_row(ix, event, cx)
|
||||
}
|
||||
}
|
||||
thread("inbox-row", ix, item, first, last, cx)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open(root, kind, address.clone(), window, cx)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group the notification groups and own activity into one section per repository.
|
||||
fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxSection> {
|
||||
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
|
||||
|
||||
for (ix, item) in notifications.iter().enumerate() {
|
||||
if item.archived {
|
||||
continue;
|
||||
}
|
||||
|
||||
let address = item.address.clone();
|
||||
let section = by_repo
|
||||
.entry(address.clone())
|
||||
.or_insert_with(move || InboxSection {
|
||||
address,
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
|
||||
if item.is_unread() {
|
||||
section.unread += 1;
|
||||
}
|
||||
|
||||
section.latest = section.latest.max(item.latest_activity());
|
||||
section.entries.push(InboxEntry::Notification(ix));
|
||||
}
|
||||
|
||||
for (ix, event) in activity.iter().enumerate() {
|
||||
let address = repo_address(event);
|
||||
let section = by_repo
|
||||
.entry(address.clone())
|
||||
.or_insert_with(move || InboxSection {
|
||||
address,
|
||||
unread: 0,
|
||||
entries: Vec::new(),
|
||||
latest: Timestamp::default(),
|
||||
});
|
||||
|
||||
section.latest = section.latest.max(event.created_at);
|
||||
section.entries.push(InboxEntry::Activity(ix));
|
||||
}
|
||||
|
||||
let mut sections: Vec<InboxSection> = by_repo.into_values().collect();
|
||||
|
||||
for section in &mut sections {
|
||||
section.entries.sort_by(|a, b| {
|
||||
entry_time(b, notifications, activity).cmp(&entry_time(a, notifications, activity))
|
||||
});
|
||||
}
|
||||
|
||||
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
|
||||
sections
|
||||
}
|
||||
|
||||
/// Timestamp an entry is ordered by.
|
||||
fn entry_time(entry: &InboxEntry, notifications: &[InboxItem], activity: &[Event]) -> Timestamp {
|
||||
match entry {
|
||||
InboxEntry::Notification(ix) => notifications
|
||||
.get(*ix)
|
||||
.map(InboxItem::latest_activity)
|
||||
.unwrap_or_default(),
|
||||
InboxEntry::Activity(ix) => activity
|
||||
.get(*ix)
|
||||
.map(|event| event.created_at)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the sections into the list of repository headers and their rows.
|
||||
fn flatten_rows(sections: &[InboxSection]) -> Vec<InboxRow> {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (section_ix, section) in sections.iter().enumerate() {
|
||||
rows.push(InboxRow::Repo(section_ix));
|
||||
|
||||
if section.entries.is_empty() {
|
||||
rows.push(InboxRow::Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.extend(
|
||||
(0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)),
|
||||
);
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
/// Repository an activity event belongs to, from its `a` tag.
|
||||
fn repo_address(event: &Event) -> Option<RepoAddr> {
|
||||
event
|
||||
.tags
|
||||
.coordinates()
|
||||
.find(|address| address.kind == Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// Display name of the repository at `addr`, from the announcement store.
|
||||
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let addr = addr?;
|
||||
@@ -495,7 +419,6 @@ fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
.map(|announcement| announcement.name().map(SharedString::from))
|
||||
}
|
||||
|
||||
/// Header of a repository section.
|
||||
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
let name =
|
||||
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
|
||||
@@ -519,83 +442,52 @@ fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Placeholder under a repository header that has nothing to show.
|
||||
fn empty_section_row(cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().secondary)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary.alpha(0.6))
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from("No activity yet."))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Open the repository of a notification group,
|
||||
/// and the issue or pull request detail when the group's root is one.
|
||||
fn open_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
root: EventId,
|
||||
kind: Option<Kind>,
|
||||
address: Option<RepoAddr>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(address) = address else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(announcement) = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == address)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let detail = open_repo_panel(dock_area, &announcement, window, cx);
|
||||
let Some(store) = detail.read(cx).store() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let item = match kind {
|
||||
Some(Kind::GitIssue) => RepoItem::Issue(root),
|
||||
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
|
||||
Some(Kind::GitPatch) => RepoItem::Patch,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
open_repo_item(dock_area, store, item, window, cx);
|
||||
}
|
||||
|
||||
fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful<Div> {
|
||||
let Some(newest) = item.events.first() else {
|
||||
return div().id((prefix, ix));
|
||||
};
|
||||
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let profile = profile_store.read(cx).get(&newest.pubkey);
|
||||
let author = profile.name();
|
||||
|
||||
let picture = profile.picture();
|
||||
let kind = item.root_kind.unwrap_or(newest.kind);
|
||||
let subject = SharedString::from(activity_subject(newest));
|
||||
let age = relative_time(item.latest_activity());
|
||||
fn thread(
|
||||
prefix: &'static str,
|
||||
ix: usize,
|
||||
item: &InboxItem,
|
||||
first: bool,
|
||||
last: bool,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let title = SharedString::from(item.title());
|
||||
let unread = item.is_unread();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let me = backend.read(cx).current_user();
|
||||
|
||||
let mut timeline = v_flex().gap_2().w_full();
|
||||
|
||||
for event in item.timeline(MAX_SUB_ACTIVITIES) {
|
||||
timeline = timeline.child(sub_activity(&event, me, cx));
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.id((prefix, ix))
|
||||
.h_16()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.gap_1()
|
||||
.justify_center()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().secondary.alpha(0.6))
|
||||
.when(first, |this| this.rounded_t(cx.theme().radius))
|
||||
.when(last, |this| this.rounded_b(cx.theme().radius))
|
||||
.when(!last, |this| {
|
||||
this.border_b_1().border_color(cx.theme().background)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -606,14 +498,14 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App)
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(kind_icon(kind)),
|
||||
.child(Icon::new(IconName::Bell)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(subject),
|
||||
.child(title),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.when(unread, |this| {
|
||||
@@ -626,119 +518,63 @@ fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(div().w_6().flex_shrink_0())
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author.clone()),
|
||||
)
|
||||
.child("opened")
|
||||
.child(SharedString::from(kind_label(kind)))
|
||||
.child(div().flex_1())
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(timeline)
|
||||
}
|
||||
|
||||
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
|
||||
let kind = event.kind;
|
||||
let subject = SharedString::from(activity_subject(event));
|
||||
let age = relative_time(event.created_at);
|
||||
fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx).read(cx);
|
||||
let profile = profile_store.get(&event.pubkey);
|
||||
|
||||
v_flex()
|
||||
.id(("activity-row", ix))
|
||||
.h_16()
|
||||
let name = if Some(event.pubkey) == me {
|
||||
SharedString::from("You")
|
||||
} else {
|
||||
profile.name()
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.gap_1()
|
||||
.justify_center()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(div().w_6().flex_shrink_0())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
h_flex()
|
||||
.size_6()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(kind_icon(kind)),
|
||||
Avatar::new(name.clone())
|
||||
.picture(profile.picture())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(subject),
|
||||
)
|
||||
.child(
|
||||
h_flex().gap_2().child(div().w_6().flex_shrink_0()).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(kind_label(kind)))
|
||||
.child("·")
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
.child(name)
|
||||
.child(SharedString::from(activity_phrase(event.kind)))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(relative_time(event.created_at))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Leading icon for a notification or activity kind.
|
||||
fn kind_icon(kind: Kind) -> Icon {
|
||||
if kind == COVER_NOTE_KIND {
|
||||
return Icon::new(IconName::File).small();
|
||||
}
|
||||
|
||||
fn activity_phrase(kind: Kind) -> &'static str {
|
||||
match kind {
|
||||
Kind::GitIssue => Icon::new(CustomIconName::GitIssueOngoing),
|
||||
Kind::GitPullRequest | Kind::GitPullRequestUpdate => {
|
||||
Icon::new(CustomIconName::GitPullRequest)
|
||||
}
|
||||
Kind::GitPatch => Icon::new(CustomIconName::GitCommit),
|
||||
Kind::Comment => Icon::new(IconName::Star),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => Icon::new(CustomIconName::GitIssueOpen),
|
||||
_ => Icon::new(IconName::Bell),
|
||||
}
|
||||
.small()
|
||||
}
|
||||
|
||||
/// Short noun for a notification or activity kind.
|
||||
fn kind_label(kind: Kind) -> &'static str {
|
||||
if kind == COVER_NOTE_KIND {
|
||||
return "note";
|
||||
}
|
||||
|
||||
match kind {
|
||||
Kind::GitIssue => "issue",
|
||||
Kind::GitPullRequest => "PR",
|
||||
Kind::GitPullRequestUpdate => "PR update",
|
||||
Kind::GitPatch => "patch",
|
||||
Kind::Comment => "comment",
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => "status",
|
||||
_ => "activity",
|
||||
Kind::GitIssue => "opened an issue",
|
||||
Kind::GitPullRequest => "opened a PR",
|
||||
Kind::GitPullRequestUpdate => "updated a PR",
|
||||
Kind::GitPatch => "created a patch",
|
||||
Kind::Comment => "commented",
|
||||
Kind::GitStatusOpen => "opened a status",
|
||||
Kind::GitStatusApplied => "applied a status",
|
||||
Kind::GitStatusClosed => "closed a status",
|
||||
Kind::GitStatusDraft => "drafted a status",
|
||||
_ => "did something",
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered muted icon and message filling its container.
|
||||
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
@@ -770,7 +606,7 @@ impl BasePanel for InboxView {
|
||||
|
||||
impl Panel for InboxView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().text_sm().child(SharedString::from("Inbox"))
|
||||
tab_title(panel_avatar("inbox"), SharedString::from("Inbox"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,20 +654,30 @@ impl Render for InboxView {
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().relative().flex_1().min_h_0().px_4().pb_4().when_else(
|
||||
rows.is_empty(),
|
||||
|this| this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)),
|
||||
|this| {
|
||||
this.child(
|
||||
list(
|
||||
self.list.clone(),
|
||||
cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
|
||||
)
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.into_any_element(),
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.px_4()
|
||||
.when_else(
|
||||
rows.is_empty(),
|
||||
|this| {
|
||||
this.child(empty_state(IconName::Inbox, "You're all caught up.", cx))
|
||||
},
|
||||
|this| {
|
||||
this.child(
|
||||
list(
|
||||
self.list.clone(),
|
||||
cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
|
||||
)
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.into_any_element(),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
))
|
||||
.child(div().h_6().w_full().flex_shrink_0()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-13
@@ -1,8 +1,8 @@
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
|
||||
relative,
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription,
|
||||
Window, div, relative,
|
||||
};
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
@@ -10,19 +10,18 @@ use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::activity_subject;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||
use signed_ui::{Avatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
/// Repo store holding the issues and their statuses.
|
||||
focus_handle: FocusHandle,
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssueDetailView {
|
||||
@@ -35,11 +34,14 @@ impl IssueDetailView {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +66,9 @@ impl Panel for IssueDetailView {
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||
|
||||
div().text_sm().child(short_id)
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
|
||||
tab_title(avatar, short_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +85,12 @@ impl Render for IssueDetailView {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
|
||||
return placeholder("Issue not found", cx);
|
||||
// The store has not applied its first pass yet, the issue may still arrive.
|
||||
return if store.loaded {
|
||||
placeholder("Issue not found", cx)
|
||||
} else {
|
||||
placeholder("Loading issue...", cx)
|
||||
};
|
||||
};
|
||||
|
||||
let (title, author, picture, status, age, issue_id, content) = {
|
||||
@@ -144,9 +153,7 @@ impl Render for IssueDetailView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
UserAvatar::new(&author).picture(picture),
|
||||
)
|
||||
.child(Avatar::new(&author).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
+70
-73
@@ -4,8 +4,8 @@ use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
@@ -18,27 +18,25 @@ use gpui_component::{
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::issue_detail::IssueDetailView;
|
||||
pub(super) mod detail;
|
||||
|
||||
use self::detail::IssueDetailView;
|
||||
use super::status_list::{StatusCounts, filter_by_status};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// Height of one issue row in the virtual list.
|
||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
/// Status filter of the issues list, chosen via the header's filter buttons.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum IssueFilter {
|
||||
/// Every issue, regardless of status.
|
||||
All,
|
||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||
Open,
|
||||
/// Issues whose resolved status is [`RepoStatus::Closed`].
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl IssueFilter {
|
||||
/// Whether an issue with `status` is included by this filter.
|
||||
fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
@@ -50,37 +48,37 @@ impl IssueFilter {
|
||||
|
||||
pub struct IssuesView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the issue detail panel is opened in.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the issues and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Filter selected in the header filter buttons.
|
||||
filter: IssueFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// The filtered issue count [`Self::item_sizes`] was built for.
|
||||
issue_len: usize,
|
||||
/// Indices into the store's `issues` matching [`Self::filter`].
|
||||
visible_issues: Vec<usize>,
|
||||
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
|
||||
counts: (usize, usize, usize),
|
||||
/// Store version and filter the cached rows/counts were built from.
|
||||
cache_key: Option<(u64, IssueFilter)>,
|
||||
/// Virtual list state of the issues list.
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: IssueFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssuesView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -88,14 +86,46 @@ impl IssuesView {
|
||||
repo_name,
|
||||
filter: IssueFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
issue_len: 0,
|
||||
visible_issues: Vec::new(),
|
||||
counts: (0, 0, 0),
|
||||
cache_key: None,
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_issues, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
filter_by_status(
|
||||
&store.issues,
|
||||
|issue| store.status_of(issue),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
let visible_issues_changed = self.visible_issues != visible_issues;
|
||||
let counts_changed = self.counts != counts;
|
||||
|
||||
if !filter_changed && !visible_issues_changed && !counts_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.item_sizes = Rc::new(vec![
|
||||
size(px(0.), px(ISSUE_ROW_HEIGHT));
|
||||
visible_issues.len()
|
||||
]);
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.visible_issues = visible_issues;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Open the detail panel of `issue_id` in the dock area.
|
||||
fn open_issue_detail(
|
||||
&mut self,
|
||||
@@ -159,7 +189,7 @@ impl IssuesView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
@@ -176,8 +206,7 @@ impl IssuesView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
// Counts of the last list rebuild.
|
||||
let (total, open, closed) = self.counts;
|
||||
let counts = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -193,31 +222,31 @@ 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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -234,7 +263,6 @@ impl IssuesView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the new issue dialog, a title and a content input.
|
||||
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
|
||||
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
|
||||
@@ -306,8 +334,11 @@ impl BasePanel for IssuesView {
|
||||
}
|
||||
|
||||
impl Panel for IssuesView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(SharedString::from(format!("{}/issues", self.repo_name)))
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,41 +353,7 @@ impl Focusable for IssuesView {
|
||||
impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||
let version = self.store.read(cx).version();
|
||||
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize);
|
||||
self.visible_issues = 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();
|
||||
self.counts = counts;
|
||||
self.cache_key = Some((version, filter));
|
||||
}
|
||||
|
||||
let count = self.visible_issues.len();
|
||||
|
||||
// The virtual list's item count comes from `item_sizes`.
|
||||
// Rebuild it whenever the filtered issue count changes.
|
||||
if count != self.issue_len {
|
||||
self.issue_len = count;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
|
||||
}
|
||||
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
@@ -1,11 +1,63 @@
|
||||
mod commit_diff;
|
||||
mod dialog_state;
|
||||
pub(crate) mod discussion;
|
||||
mod inbox;
|
||||
mod repo_detail;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
mod send_patch;
|
||||
pub(crate) mod sidebar;
|
||||
mod status_list;
|
||||
pub(crate) mod tree;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, div};
|
||||
use gpui_component::{Sizable as _, h_flex};
|
||||
pub use inbox::InboxView;
|
||||
pub use repo_detail::RepoDetailView;
|
||||
pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo::RepoDetailView;
|
||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, PixelAvatar};
|
||||
|
||||
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(div().flex_shrink_0().child(avatar))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
|
||||
PixelAvatar::new(seed).xsmall().into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
||||
let seed = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| format!("{}:{}", announcement.owner, announcement.id))
|
||||
.or_else(|| store.addr().map(|addr| addr.to_string()))
|
||||
.or_else(|| {
|
||||
store
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let owner = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
|
||||
|
||||
match owner.and_then(|profile| profile.picture().map(|picture| (profile.name(), picture))) {
|
||||
Some((name, picture)) => Avatar::new(name)
|
||||
.picture(Some(picture))
|
||||
.xsmall()
|
||||
.into_any_element(),
|
||||
None => PixelAvatar::new(seed).xsmall().into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
+228
-184
@@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, relative, size,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
@@ -20,55 +20,67 @@ use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::{Event, EventId, Kind};
|
||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
||||
use signed_core::{
|
||||
activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
||||
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
||||
merge_base_of, pull_request_patch,
|
||||
};
|
||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// Height of one commit row in the commits tab's virtual list.
|
||||
const ROW_HEIGHT: f32 = 37.;
|
||||
|
||||
/// Shown once the store's first pass is applied and the root PR is still absent.
|
||||
const NOT_FOUND: &str = "Pull request not found";
|
||||
|
||||
/// A store refresh re-binds the panel, and reloads only when these change.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct PrBinding {
|
||||
description: String,
|
||||
patch: String,
|
||||
tip: Option<String>,
|
||||
base: Option<String>,
|
||||
clone_urls: Vec<Url>,
|
||||
addr: RepoAddr,
|
||||
has_patch_link: bool,
|
||||
}
|
||||
|
||||
/// Detail panel of a single pull request.
|
||||
pub struct PullRequestDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area where new panels, e.g. commit diffs, are added.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the PR, its status and comments.
|
||||
store: Entity<RepoStore>,
|
||||
/// Event id of the root PR event, kind 1618.
|
||||
/// Updates are revisions.
|
||||
/// Event id of the root PR event, kind 1618. Updates are revisions.
|
||||
pr_id: EventId,
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
/// Display name of the repository, for panels opened from here.
|
||||
repo_name: SharedString,
|
||||
/// Local clone the PR's git changes come from.
|
||||
worktree: Option<PathBuf>,
|
||||
/// Root PR's content, shown as plain text.
|
||||
description: SharedString,
|
||||
/// Tip commit of the PR, the latest update's `c` tag or the root's.
|
||||
/// Tip commit of the PR, from the latest update's `c` tag or the root.
|
||||
current_commit: Option<SharedString>,
|
||||
/// Commits of the patch series, in patch order, oldest first.
|
||||
commits: Vec<FileCommit>,
|
||||
/// The patch is being parsed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
/// Root PR inputs the in-flight diff load was started for.
|
||||
bound: Option<PrBinding>,
|
||||
/// Generation of the in-flight diff load. Stale results are discarded.
|
||||
load_generation: u64,
|
||||
/// 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
active_tab: usize,
|
||||
/// Changed-files explorer and per-file diff, like the commit and compare views.
|
||||
pane: Entity<DiffPane>,
|
||||
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
|
||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the commits tab.
|
||||
commit_scroll_handle: VirtualListScrollHandle,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestDetailView {
|
||||
@@ -85,9 +97,11 @@ impl PullRequestDetailView {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx));
|
||||
|
||||
// Defer loading until the window is ready, like the commit diff view.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.sync(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -103,158 +117,217 @@ impl PullRequestDetailView {
|
||||
commits: Vec::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
bound: None,
|
||||
load_generation: 0,
|
||||
active_tab: 0,
|
||||
pane,
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
tasks: Vec::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the PR events from the store.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
/// Snapshot the root PR from the store and reload the diff when it changed.
|
||||
///
|
||||
/// Re-runs on construction and on every store refresh. Item panels are
|
||||
/// cached by the dock, so this is the only way a panel opened before the
|
||||
/// store's first pass learns about its PR.
|
||||
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||
let loaded = self.store.read(cx).loaded;
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
|
||||
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
||||
let binding = {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
self.loading = false;
|
||||
self.error = Some("Pull request not found".into());
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
store.addr().and_then(|addr| {
|
||||
store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| {
|
||||
let update = latest_update(store.pull_requests.iter(), root);
|
||||
|
||||
let update = latest_update(store.pull_requests.iter(), root);
|
||||
let tip = update
|
||||
.and_then(current_commit_of)
|
||||
.or_else(|| current_commit_of(root));
|
||||
|
||||
let tip = update
|
||||
.and_then(current_commit_of)
|
||||
.or_else(|| current_commit_of(root));
|
||||
let base = update
|
||||
.and_then(merge_base_of)
|
||||
.or_else(|| merge_base_of(root));
|
||||
|
||||
let base = update
|
||||
.and_then(merge_base_of)
|
||||
.or_else(|| merge_base_of(root));
|
||||
let clone_urls = clone_urls_of(root)
|
||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let clone_urls = clone_urls_of(root)
|
||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()));
|
||||
|
||||
(
|
||||
root.content.clone(),
|
||||
pull_request_patch(root, store.patches.iter()),
|
||||
tip,
|
||||
base,
|
||||
clone_urls.unwrap_or_default(),
|
||||
store.addr().clone(),
|
||||
root.tags.event_ids().next().is_some(),
|
||||
)
|
||||
PrBinding {
|
||||
description: root.content.clone(),
|
||||
patch: pull_request_patch(root, store.patches.iter()),
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr: addr.clone(),
|
||||
has_patch_link: root.tags.event_ids().next().is_some(),
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
self.description = description.into();
|
||||
let Some(binding) = binding else {
|
||||
self.sync_missing(loaded, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_diffs(&patch) }
|
||||
if self.bound.as_ref() == Some(&binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.bound = Some(binding.clone());
|
||||
self.load_diff(binding, cx);
|
||||
}
|
||||
|
||||
/// The store does not hold the root PR yet, or at all.
|
||||
///
|
||||
/// Loading until the first pass is applied, not found afterwards.
|
||||
fn sync_missing(&mut self, loaded: bool, cx: &mut Context<Self>) {
|
||||
self.bound = None;
|
||||
|
||||
if !loaded {
|
||||
if !self.loading || self.error.is_some() {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.error.as_deref() != Some(NOT_FOUND) {
|
||||
self.loading = false;
|
||||
self.error = Some(NOT_FOUND.into());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bound PR's changed files and commits.
|
||||
///
|
||||
/// Nostr-backed pull requests parse the patch series, git-backed ones fetch
|
||||
/// the clone and diff the `merge-base..tip` range.
|
||||
fn load_diff(&mut self, binding: PrBinding, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.description = binding.description.clone().into();
|
||||
self.current_commit = binding.tip.clone().map(SharedString::from);
|
||||
cx.notify();
|
||||
|
||||
self.load_generation = self.load_generation.wrapping_add(1);
|
||||
let generation = self.load_generation;
|
||||
|
||||
let PrBinding {
|
||||
patch,
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr,
|
||||
has_patch_link,
|
||||
..
|
||||
} = binding;
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_diffs(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_commits(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||
// Fetch the clone and diff the `merge-base..tip` range.
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = base.clone();
|
||||
let tip = tip.clone();
|
||||
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
|
||||
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
|
||||
let tip =
|
||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||
None => {
|
||||
let head = repo
|
||||
.head_id()
|
||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||
let commits =
|
||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await;
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_commits(&patch) }
|
||||
})
|
||||
.await;
|
||||
let (diff, commits, worktree) = match git {
|
||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||
None => (nostr_diff, nostr_commits, None),
|
||||
};
|
||||
|
||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||
// Fetch the clone and diff the `merge-base..tip` range.
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
// A newer binding superseded this load.
|
||||
if this.load_generation != generation {
|
||||
return;
|
||||
}
|
||||
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = merge_base.clone();
|
||||
let tip = current_commit.clone();
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||
this.commits = commits;
|
||||
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
|
||||
let tip = tip
|
||||
.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||
None => {
|
||||
let head = repo
|
||||
.head_id()
|
||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let diff =
|
||||
signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||
let commits =
|
||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
||||
let (diff, commits, worktree) = match git {
|
||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||
None => (nostr_diff, nostr_commits, None),
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.current_commit = current_commit.map(SharedString::from);
|
||||
this.commit_item_sizes =
|
||||
Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||
this.commits = commits;
|
||||
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||
@@ -271,6 +344,7 @@ impl PullRequestDetailView {
|
||||
|
||||
let panel = cx.new(|cx| {
|
||||
CommitDiffView::new(
|
||||
self.store.clone(),
|
||||
worktree,
|
||||
self.repo_name.clone(),
|
||||
commit_id.into(),
|
||||
@@ -379,7 +453,7 @@ impl PullRequestDetailView {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
UserAvatar::new(author.clone())
|
||||
Avatar::new(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
@@ -435,9 +509,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Full-height Commits tab.
|
||||
///
|
||||
/// Every commit of the patch series, or a status message while loading or empty.
|
||||
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -485,9 +556,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row of the commits tab, id, summary, author and time.
|
||||
///
|
||||
/// Clicking a row opens the commit's diff in the bottom dock.
|
||||
fn render_commit_row(
|
||||
&self,
|
||||
ix: usize,
|
||||
@@ -540,7 +608,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Always-visible header with a status badge and title, like the issue panel.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let current_commit = self.current_commit.clone();
|
||||
let (title, status, branch, author) = {
|
||||
@@ -642,7 +709,6 @@ impl PullRequestDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the update pull request dialog.
|
||||
fn open_update_pull_request_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
root: Event,
|
||||
@@ -703,7 +769,6 @@ fn open_update_pull_request_dialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// The `c` tag of a PR event, the commit the proposal points at.
|
||||
/// One-line commit metadata for the commits list.
|
||||
///
|
||||
/// Author and relative time, whichever is available.
|
||||
@@ -725,15 +790,17 @@ impl BasePanel for PullRequestDetailView {
|
||||
}
|
||||
|
||||
impl Panel for PullRequestDetailView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let hex = self.pr_id.to_hex();
|
||||
let id = SharedString::from(&hex[..8]);
|
||||
let title = if self.repo_name.is_empty() {
|
||||
let label = if self.repo_name.is_empty() {
|
||||
id
|
||||
} else {
|
||||
SharedString::from(format!("{}/{}", self.repo_name, id))
|
||||
};
|
||||
div().text_sm().child(title)
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,26 +828,3 @@ impl Render for PullRequestDetailView {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
|
||||
#[test]
|
||||
fn commit_meta_combines_author_and_time() {
|
||||
let commit = |author: &str, time: i64| FileCommit {
|
||||
id: COMMIT_HEX.into(),
|
||||
summary: "summary".into(),
|
||||
description: None,
|
||||
author: author.into(),
|
||||
time,
|
||||
};
|
||||
|
||||
assert_eq!(commit_meta(&commit("Alice", 0)), "Alice");
|
||||
assert_eq!(commit_meta(&commit("", 0)), "");
|
||||
assert!(!commit_meta(&commit("", 1_000_000)).is_empty());
|
||||
assert!(!commit_meta(&commit("Alice", 1_000_000)).is_empty());
|
||||
}
|
||||
}
|
||||
+75
-96
@@ -4,8 +4,8 @@ use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::alert::Alert;
|
||||
@@ -16,34 +16,31 @@ use gpui_component::{
|
||||
use nostr::prelude::{EventId, Kind};
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::RepoAction;
|
||||
use super::new_pull_request::open_new_pull_panel;
|
||||
use super::pull_request_detail::PullRequestDetailView;
|
||||
pub(super) mod detail;
|
||||
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;
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// Height of one pull request row in the virtual list.
|
||||
const ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
/// Status filter of the pull request list, chosen via the header's filter buttons.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PullRequestFilter {
|
||||
/// Every pull request, regardless of status.
|
||||
All,
|
||||
/// Pull requests whose resolved status is [`RepoStatus::Open`].
|
||||
Open,
|
||||
/// Pull requests whose resolved status is [`RepoStatus::Closed`].
|
||||
Closed,
|
||||
/// Pull requests whose resolved status is [`RepoStatus::Draft`].
|
||||
Draft,
|
||||
/// Pull requests whose resolved status is [`RepoStatus::Applied`].
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl PullRequestFilter {
|
||||
/// Whether a pull request with `status` is included by this filter.
|
||||
fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
@@ -57,37 +54,38 @@ impl PullRequestFilter {
|
||||
|
||||
pub struct PullRequestsView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the detail panels are added to.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the pull requests and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Filter selected in the header filter buttons.
|
||||
filter: PullRequestFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// The filtered pull request count [`Self::item_sizes`] was built for.
|
||||
pr_len: usize,
|
||||
/// 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),
|
||||
/// Store version and filter the cached rows/counts were built from.
|
||||
cache_key: Option<(u64, PullRequestFilter)>,
|
||||
/// Virtual list state of the pull requests list.
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged,
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: PullRequestFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestsView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -95,15 +93,44 @@ impl PullRequestsView {
|
||||
repo_name,
|
||||
filter: PullRequestFilter::Open,
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
pr_len: 0,
|
||||
visible_prs: Vec::new(),
|
||||
counts: (0, 0, 0, 0, 0),
|
||||
cache_key: None,
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: PullRequestFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the detail panel of `pr_id` in the dock area.
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_prs, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
let roots = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.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;
|
||||
|
||||
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
||||
return;
|
||||
}
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); visible_prs.len()]);
|
||||
self.visible_prs = visible_prs;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_pull_request_detail(
|
||||
&mut self,
|
||||
pr_id: EventId,
|
||||
@@ -129,9 +156,6 @@ impl PullRequestsView {
|
||||
});
|
||||
}
|
||||
|
||||
/// Render one row of the pull request list.
|
||||
///
|
||||
/// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
|
||||
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||
let pr_id = pr.id;
|
||||
@@ -179,7 +203,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
@@ -196,8 +220,7 @@ impl PullRequestsView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
// Counts of the last list rebuild.
|
||||
let (total, open, closed, draft, merged) = self.counts;
|
||||
let counts = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -213,51 +236,51 @@ 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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
)
|
||||
.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;
|
||||
cx.notify();
|
||||
this.rebuild(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -311,11 +334,11 @@ impl BasePanel for PullRequestsView {
|
||||
}
|
||||
|
||||
impl Panel for PullRequestsView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(SharedString::from(format!(
|
||||
"{}/pull-requests",
|
||||
self.repo_name
|
||||
)))
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,55 +353,11 @@ impl Focusable for PullRequestsView {
|
||||
impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||
let version = self.store.read(cx).version();
|
||||
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
self.visible_prs = 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();
|
||||
|
||||
self.counts = counts;
|
||||
self.cache_key = Some((version, filter));
|
||||
}
|
||||
|
||||
let count = self.visible_prs.len();
|
||||
|
||||
// The virtual list's item count comes from `item_sizes`.
|
||||
// Rebuild it whenever the filtered pull request count changes.
|
||||
if count != self.pr_len {
|
||||
self.pr_len = count;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]);
|
||||
}
|
||||
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let view = cx.entity().clone();
|
||||
|
||||
// Non-fatal warnings and errors of the last action, like creating or updating a PR.
|
||||
// Shown as dismissible banners above the list.
|
||||
let (last_error, last_warning) = {
|
||||
let store = self.store.read(cx);
|
||||
(store.last_error.clone(), store.last_warning.clone())
|
||||
+77
-109
@@ -26,27 +26,23 @@ 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_ui::{CountBadge, placeholder};
|
||||
use signed_state::{
|
||||
Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path,
|
||||
};
|
||||
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
||||
|
||||
use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::ref_selector_trigger;
|
||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
/// The new pull request panel of a repository.
|
||||
pub struct NewPullRequestView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the panel lives in, commit diffs are opened there.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Store of the target repository, source of the announced HEAD default.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// The user's local checkout.
|
||||
repo_path: Option<PathBuf>,
|
||||
/// Branches of the checkout, backing both selectors in checkout mode.
|
||||
/// Backs both selectors in checkout mode.
|
||||
branches: Vec<SharedString>,
|
||||
/// Fork-backed compare state.
|
||||
fork: Option<ForkCompare>,
|
||||
/// Selected base branch, the PR target, stored as a short name.
|
||||
base: SharedString,
|
||||
@@ -54,57 +50,47 @@ pub struct NewPullRequestView {
|
||||
compare: SharedString,
|
||||
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Title input, required.
|
||||
subject: Entity<InputState>,
|
||||
/// Description input, optional.
|
||||
description: Entity<TextareaState>,
|
||||
/// Merge base of the selected branches, `None` until the compare loads.
|
||||
merge_base: Option<String>,
|
||||
/// Commits in `merge_base..compare`, newest first.
|
||||
commits: Option<Vec<signed_git::FileCommit>>,
|
||||
/// The compare is being computed.
|
||||
loading: bool,
|
||||
/// Error of the last compare or submit attempt.
|
||||
error: Option<SharedString>,
|
||||
/// A submit, patch generation and publish, is in flight.
|
||||
submitting: bool,
|
||||
/// Bumped on every branch switch, stale compare results are discarded.
|
||||
compare_generation: u64,
|
||||
/// Active tab, 0 = Files and 1 = Commits.
|
||||
/// 0 = Files, 1 = Commits.
|
||||
active_tab: usize,
|
||||
/// The compare diff, the Files tab body.
|
||||
pane: Entity<DiffPane>,
|
||||
/// Virtual list state of the Commits tab.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A fork-backed compare.
|
||||
struct ForkCompare {
|
||||
/// Fork announcement the compare branch is imported from.
|
||||
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,
|
||||
}
|
||||
|
||||
impl ForkCompare {
|
||||
/// The full ref of the base branch `name` in the mirror.
|
||||
fn base_ref(name: &str) -> String {
|
||||
format!("refs/remotes/origin/{name}")
|
||||
}
|
||||
|
||||
/// The full ref of the compare branch `name` in the mirror.
|
||||
fn compare_ref(&self, name: &str) -> String {
|
||||
format!("refs/fork/{}/{}", self.namespace, name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The display name of an announcement.
|
||||
///
|
||||
/// Its human-readable name, falling back to the repository id.
|
||||
fn fork_display_name(announcement: &Announcement) -> SharedString {
|
||||
announcement
|
||||
.name
|
||||
@@ -113,7 +99,6 @@ fn fork_display_name(announcement: &Announcement) -> SharedString {
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
}
|
||||
|
||||
/// A short label of a fork's owner for the source picker, a hex prefix.
|
||||
fn shorten_owner(owner: &PublicKey) -> String {
|
||||
let hex = owner.to_hex();
|
||||
hex.chars().take(10).collect()
|
||||
@@ -132,9 +117,6 @@ fn truncate_label(label: &str) -> SharedString {
|
||||
SharedString::from(label)
|
||||
}
|
||||
|
||||
/// The compare-source menu entry of one local checkout folder.
|
||||
///
|
||||
/// Applies the folder directly, no picker.
|
||||
fn checkout_source_item(
|
||||
view: WeakEntity<NewPullRequestView>,
|
||||
path: PathBuf,
|
||||
@@ -163,7 +145,6 @@ fn checkout_source_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// The compare-source menu entry prompting for an arbitrary folder.
|
||||
fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuItem {
|
||||
PopupMenuItem::element(move |_window, cx| {
|
||||
source_row(
|
||||
@@ -180,9 +161,6 @@ fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuI
|
||||
})
|
||||
}
|
||||
|
||||
/// The compare-source menu entry of one announced fork.
|
||||
///
|
||||
/// Imports its branches into the target's mirror and switches the panel to fork mode.
|
||||
fn fork_source_item(
|
||||
view: WeakEntity<NewPullRequestView>,
|
||||
announcement: Announcement,
|
||||
@@ -208,7 +186,6 @@ fn fork_source_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of the compare-source menu, icon, title and a muted subtitle.
|
||||
fn source_row<T>(icon: impl Into<Icon>, title: T, subtitle: T, cx: &App) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
@@ -306,7 +283,24 @@ impl NewPullRequestView {
|
||||
),
|
||||
];
|
||||
|
||||
let mut view = Self {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
let Some(addr) = this.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(path) = CheckoutsStore::global(cx)
|
||||
.read(cx)
|
||||
.associations_of(&addr)
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.apply_folder_path(path, window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
@@ -330,24 +324,11 @@ impl NewPullRequestView {
|
||||
pane,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
// Prefill with the store's freshest associated checkout, no folder dialog.
|
||||
let addr = view.store.read(cx).addr().clone();
|
||||
if let Some(path) = CheckoutsStore::global(cx)
|
||||
.read(cx)
|
||||
.associations_of(&addr)
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
view.apply_folder_path(path, window, cx);
|
||||
}
|
||||
|
||||
view
|
||||
}
|
||||
|
||||
/// Whether a compare source, a checkout or a fork, is applied.
|
||||
fn has_source(&self) -> bool {
|
||||
self.repo_path.is_some() || self.fork.is_some()
|
||||
}
|
||||
@@ -384,7 +365,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for a local checkout.
|
||||
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
@@ -412,12 +392,11 @@ impl NewPullRequestView {
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Apply `path` as the local checkout, no picker.
|
||||
///
|
||||
/// Branches and current branch are read off the UI thread, then applied.
|
||||
/// Branches and the current branch are read off the UI thread, then applied.
|
||||
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let path = path.to_string_lossy().to_string();
|
||||
|
||||
@@ -443,10 +422,10 @@ impl NewPullRequestView {
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Apply a picked checkout, filling the selectors and loading the compare.
|
||||
fn apply_checkout(
|
||||
&mut self,
|
||||
path: String,
|
||||
@@ -497,11 +476,12 @@ impl NewPullRequestView {
|
||||
|
||||
// Remember this folder as a checkout of the target repository.
|
||||
// The next panel pre-fills it.
|
||||
let addr = self.store.read(cx).addr().clone();
|
||||
let checkout_store = CheckoutsStore::global(cx);
|
||||
checkout_store.update(cx, |store, cx| {
|
||||
store.record(PathBuf::from(&path), addr, cx);
|
||||
});
|
||||
if let Some(addr) = self.store.read(cx).addr().cloned() {
|
||||
let checkout_store = CheckoutsStore::global(cx);
|
||||
checkout_store.update(cx, |store, cx| {
|
||||
store.record(PathBuf::from(&path), addr, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let branches = self.branches.clone();
|
||||
let base = SharedString::from(base.clone());
|
||||
@@ -523,20 +503,21 @@ impl NewPullRequestView {
|
||||
self.reload_compare(window, cx);
|
||||
}
|
||||
|
||||
/// The base repository of the panel, its address and announced EUC.
|
||||
///
|
||||
/// Used to find fork candidates.
|
||||
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
|
||||
/// Used to find fork candidates. `None` while the repository is not announced.
|
||||
fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option<String>)> {
|
||||
let store = self.store.read(cx);
|
||||
let addr = store.addr()?.clone();
|
||||
let euc = store.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
(store.addr().clone(), euc)
|
||||
Some((addr, euc))
|
||||
}
|
||||
|
||||
/// Announced forks of the target repository a compare can use, own first.
|
||||
///
|
||||
/// Re-read whenever the picker opens.
|
||||
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
|
||||
let (base, euc) = self.base_repo(cx);
|
||||
let Some((base, euc)) = self.base_repo(cx) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let user = Backend::global(cx).read(cx).current_user();
|
||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||
fork_candidates(&announcements, &base, euc.as_deref(), user)
|
||||
@@ -545,7 +526,6 @@ impl NewPullRequestView {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compare against an announced fork.
|
||||
fn choose_fork(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -557,9 +537,10 @@ impl NewPullRequestView {
|
||||
.as_ref()
|
||||
.is_some_and(|fork| fork.announcement.addr() == announcement.addr());
|
||||
|
||||
let (base, _euc) = self.base_repo(cx);
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let mirror_path = cache.repo_path(&base);
|
||||
let Some((base, _euc)) = self.base_repo(cx) else {
|
||||
return;
|
||||
};
|
||||
let mirror_path = repo_mirror_path(&base);
|
||||
let namespace = fork_namespace(&announcement);
|
||||
let clone_urls = announcement.clone.clone();
|
||||
|
||||
@@ -586,27 +567,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 {
|
||||
// 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.
|
||||
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,
|
||||
@@ -666,10 +640,10 @@ impl NewPullRequestView {
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Apply an imported fork, filling the selectors and loading the compare.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_fork(
|
||||
&mut self,
|
||||
@@ -687,8 +661,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;
|
||||
@@ -702,8 +674,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;
|
||||
}
|
||||
@@ -716,11 +687,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);
|
||||
|
||||
@@ -774,7 +742,6 @@ impl NewPullRequestView {
|
||||
self.reload_compare(window, cx);
|
||||
}
|
||||
|
||||
/// Recompute `merge_base..compare` of the selected branches on a background task.
|
||||
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
@@ -820,16 +787,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))
|
||||
}
|
||||
})
|
||||
@@ -865,10 +835,9 @@ impl NewPullRequestView {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Publish the pull request.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting || self.loading {
|
||||
return;
|
||||
@@ -947,20 +916,21 @@ impl NewPullRequestView {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Open the diff of `commit_id`, from the Commits tab, in a new panel.
|
||||
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| {
|
||||
CommitDiffView::new(
|
||||
self.store.clone(),
|
||||
repo_path,
|
||||
self.repo_name.clone(),
|
||||
commit_id.into(),
|
||||
@@ -1108,7 +1078,6 @@ impl NewPullRequestView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The source picker's trigger, a truncated label of the applied source.
|
||||
fn source_trigger(&self) -> SharedString {
|
||||
match &self.fork {
|
||||
Some(fork) => truncate_label(&fork_display_name(&fork.announcement)),
|
||||
@@ -1119,14 +1088,17 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the compare-source menu.
|
||||
fn source_menu(
|
||||
&self,
|
||||
cx: &Context<Self>,
|
||||
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
|
||||
let view = cx.entity().downgrade();
|
||||
let addr = self.store.read(cx).addr().clone();
|
||||
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
|
||||
let associated = self
|
||||
.store
|
||||
.read(cx)
|
||||
.addr()
|
||||
.map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr))
|
||||
.unwrap_or_default();
|
||||
|
||||
let active_path = (self.fork.is_none())
|
||||
.then(|| self.repo_path.clone())
|
||||
@@ -1172,7 +1144,6 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title and description inputs.
|
||||
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.px_4()
|
||||
@@ -1183,7 +1154,6 @@ impl NewPullRequestView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The Files/Commits tab bar, mirroring the repository panel's.
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len());
|
||||
let commits = self.commits.as_ref().map_or(0, |commits| commits.len());
|
||||
@@ -1255,7 +1225,6 @@ impl NewPullRequestView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The active tab's body.
|
||||
fn render_content(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -1341,8 +1310,7 @@ impl NewPullRequestView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the new pull request panel in the center dock.
|
||||
pub(super) fn open_new_pull_panel(
|
||||
pub(crate) fn open_new_pull_panel(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
@@ -1362,11 +1330,11 @@ impl BasePanel for NewPullRequestView {
|
||||
}
|
||||
|
||||
impl Panel for NewPullRequestView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(SharedString::from(format!(
|
||||
"{}/new-pull-request",
|
||||
self.repo_name
|
||||
)))
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-14
@@ -5,9 +5,8 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::ProfileStore;
|
||||
use signed_ui::{UserAvatar, middle_truncate};
|
||||
use signed_ui::{Avatar, middle_truncate};
|
||||
|
||||
/// Open the About dialog showing every field of the announcement event.
|
||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
@@ -21,7 +20,6 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
|
||||
});
|
||||
}
|
||||
|
||||
/// The announcement's fields as labeled rows.
|
||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
@@ -116,7 +114,6 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
||||
}
|
||||
|
||||
/// One info row with a small muted label above the value.
|
||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
@@ -132,7 +129,6 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Plain text value, wrapping within the dialog.
|
||||
fn text<T>(value: T) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
@@ -147,7 +143,6 @@ where
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A mono-spaced value with a copy button, for hex identifiers.
|
||||
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -164,10 +159,6 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row per maintainer with avatar and display name.
|
||||
/// The display name falls back to a shortened npub.
|
||||
///
|
||||
/// A copy button copies the full pubkey.
|
||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
v_flex()
|
||||
@@ -182,7 +173,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
@@ -197,9 +188,6 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row per item of a multi-value tag.
|
||||
///
|
||||
/// The value is truncated to a single line, with a copy button for the full value.
|
||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, WeakEntity, Window};
|
||||
use gpui_base::dock::PanelView;
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::issues::detail::IssueDetailView;
|
||||
use crate::views::pull_requests::detail::PullRequestDetailView;
|
||||
|
||||
/// Open repository as a panel in the dock's center.
|
||||
pub(crate) fn open_repo_panel(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<RepoDetailView> {
|
||||
let detail = cx
|
||||
.new(|cx| RepoDetailView::new(dock_area.clone(), addr.clone(), hint.cloned(), window, cx));
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(detail.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
detail
|
||||
}
|
||||
|
||||
/// The nostr store of `addr`'s repository, without opening a repository panel.
|
||||
fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity<RepoStore> {
|
||||
cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx))
|
||||
}
|
||||
|
||||
/// An item of a repository to open from outside its detail panel.
|
||||
pub(crate) enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// The repository store is built here.
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel: Arc<dyn PanelView> =
|
||||
match item {
|
||||
RepoItem::Issue(issue_id) => {
|
||||
let store = repo_store(addr, hint, cx);
|
||||
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
||||
}
|
||||
RepoItem::PullRequest(pr_id) => {
|
||||
let store = repo_store(addr, hint, cx);
|
||||
panel_handle(cx.new(|cx| {
|
||||
PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)
|
||||
}))
|
||||
}
|
||||
RepoItem::Patch => return,
|
||||
};
|
||||
|
||||
let Some(dock_area) = dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel, window, cx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use signed_state::CheckoutStatus;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Banners {
|
||||
dismissed: HashSet<(PathBuf, String)>,
|
||||
ready_requested: bool,
|
||||
/// Re-requested only when the announced HEAD or the base default changes.
|
||||
ready_head: Option<String>,
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
}
|
||||
|
||||
impl Banners {
|
||||
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
|
||||
self.dismissed
|
||||
.contains(&(status.path.clone(), status.branch.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
|
||||
self.dismissed
|
||||
.insert((status.path.clone(), status.branch.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
|
||||
(self.ready_requested, &self.ready_head)
|
||||
}
|
||||
|
||||
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
|
||||
self.ready_requested = true;
|
||||
self.ready_head = head;
|
||||
}
|
||||
|
||||
pub(super) fn set_statuses(
|
||||
&mut self,
|
||||
ready: Vec<CheckoutStatus>,
|
||||
push: Vec<CheckoutStatus>,
|
||||
) -> bool {
|
||||
let changed = ready != self.ready_statuses || push != self.push_statuses;
|
||||
self.ready_statuses = ready;
|
||||
self.push_statuses = push;
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Editor, EditorState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_git::{FileCommit, WorktreeSnapshot};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use crate::views::tree::{TreeItemSeed, tree_items};
|
||||
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
const MAX_PREVIEWED_FILES: usize = 32;
|
||||
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
enum FileContent {
|
||||
Text(String),
|
||||
Binary,
|
||||
TooLarge,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct MarkdownView {
|
||||
/// `None` means the repository README.
|
||||
path: Option<SharedString>,
|
||||
state: Entity<TextViewState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
path: SharedString,
|
||||
state: Entity<EditorState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
pub(super) struct RepoFilesView {
|
||||
tree_state: Entity<TreeState>,
|
||||
worktree: Option<PathBuf>,
|
||||
worktree_paths: Vec<String>,
|
||||
md: Option<MarkdownView>,
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
file_order: VecDeque<String>,
|
||||
preview_bytes: usize,
|
||||
loading_files: HashSet<String>,
|
||||
commits: HashMap<String, FileCommit>,
|
||||
pending_commits: Vec<String>,
|
||||
loading_commits: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoFilesView {
|
||||
pub(super) fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
tree_state: cx.new(|cx| TreeState::new(cx)),
|
||||
worktree: None,
|
||||
worktree_paths: Vec::new(),
|
||||
md: None,
|
||||
code: None,
|
||||
readme_name: None,
|
||||
selected_file: None,
|
||||
files: HashMap::new(),
|
||||
file_order: VecDeque::new(),
|
||||
preview_bytes: 0,
|
||||
loading_files: HashSet::new(),
|
||||
commits: HashMap::new(),
|
||||
pending_commits: Vec::new(),
|
||||
loading_commits: false,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_worktree(&mut self, path: PathBuf) {
|
||||
self.worktree = Some(path);
|
||||
}
|
||||
|
||||
pub(super) fn apply_entries(
|
||||
&mut self,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.worktree_paths = paths;
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(tree_items(tree, false), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Point the README pane at `path`/`bytes`, or clear it when absent.
|
||||
///
|
||||
/// Returns whether the pane changed.
|
||||
pub(super) fn set_readme(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some((path, bytes)) = path.zip(bytes) else {
|
||||
let changed = self.readme_name.is_some() || self.md.is_some();
|
||||
self.readme_name = None;
|
||||
self.md = None;
|
||||
return changed;
|
||||
};
|
||||
|
||||
let name: SharedString = path.to_string_lossy().into();
|
||||
let mut changed = self.readme_name.as_ref() != Some(&name);
|
||||
self.readme_name = Some(name);
|
||||
self.load_commit(&path.to_string_lossy(), cx);
|
||||
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
changed |= self.set_markdown(None, &text, cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drop every cached preview and the README, e.g. on a branch switch.
|
||||
pub(super) fn clear_previews(&mut self) {
|
||||
self.selected_file = None;
|
||||
self.files.clear();
|
||||
self.file_order.clear();
|
||||
self.preview_bytes = 0;
|
||||
self.loading_files.clear();
|
||||
self.commits.clear();
|
||||
self.pending_commits.clear();
|
||||
self.loading_commits = false;
|
||||
self.md = None;
|
||||
self.code = None;
|
||||
self.readme_name = None;
|
||||
}
|
||||
|
||||
/// Refresh after the mirror caught up with the remote.
|
||||
///
|
||||
/// Unlike a branch switch this keeps the selection and previews: it rebuilds
|
||||
/// the tree, drops previews of files the refresh removed and re-renders the
|
||||
/// README when it is on screen.
|
||||
///
|
||||
/// Returns whether the tree, a preview or the README changed.
|
||||
pub(super) fn catch_up(
|
||||
&mut self,
|
||||
snapshot: &WorktreeSnapshot,
|
||||
tree: Vec<TreeItemSeed>,
|
||||
paths: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if paths != self.worktree_paths {
|
||||
self.apply_entries(tree, paths, cx);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let present: HashSet<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut previewed: Vec<String> = Vec::new();
|
||||
|
||||
previewed.extend(self.files.keys().cloned());
|
||||
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
|
||||
|
||||
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
previewed.sort();
|
||||
previewed.dedup();
|
||||
|
||||
for path in previewed {
|
||||
if !present.contains(&path) {
|
||||
self.drop_preview_of(&path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if self.selected_file.is_none() {
|
||||
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn pane_title(&self) -> SharedString {
|
||||
self.selected_file
|
||||
.clone()
|
||||
.or_else(|| self.readme_name.clone())
|
||||
.unwrap_or_else(|| "Overview".into())
|
||||
}
|
||||
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1().min_h_0().child(tree(
|
||||
&tree_state,
|
||||
move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()), cx)
|
||||
} else {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None, cx)
|
||||
} else {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().muted)
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.xsmall()
|
||||
.text()
|
||||
.label(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("repo-content").flex_1().min_h_0().child(body))
|
||||
}
|
||||
|
||||
fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(md) = &self.md
|
||||
&& md.path == path
|
||||
&& md.source_hash == hash
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let state = cx.new(|cx| TextViewState::markdown("", cx));
|
||||
state.update(cx, |state, cx| state.push_str(text, cx));
|
||||
|
||||
self.md = Some(MarkdownView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
TextView::new(&md.state)
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hash = source_hash(text);
|
||||
|
||||
if let Some(code) = &self.code
|
||||
&& code.path == path
|
||||
&& code.source_hash == hash
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let language = code_language(path.as_ref()).unwrap_or("text");
|
||||
let state = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
.language(language)
|
||||
.default_value(text)
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
Editor::new(&code.state)
|
||||
.readonly(true)
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
|
||||
if self.files.contains_key(path) {
|
||||
if let Some(FileContent::Text(text)) = self.files.get(path) {
|
||||
let text = text.clone();
|
||||
if is_markdown_path(path) {
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
|
||||
self.set_markdown(Some(path.into()), &text, cx);
|
||||
}
|
||||
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
|
||||
self.set_code(path.into(), &text, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.loading_files.contains(path) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let rel = Path::new(path);
|
||||
let unsafe_path = rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if unsafe_path {
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
self.load_commit(&path, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let full = worktree.join(&path_for_read);
|
||||
|
||||
let metadata = match std::fs::metadata(&full) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
|
||||
return Ok(FileContent::TooLarge);
|
||||
}
|
||||
|
||||
let bytes = match std::fs::read(&full) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(text) => Ok(FileContent::Text(text)),
|
||||
Err(_) => Ok(FileContent::Binary),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading_files.remove(&path);
|
||||
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
if let FileContent::Text(text) = &kind {
|
||||
if is_markdown_path(&path) {
|
||||
let same = this.md.as_ref().map(|md| md.path.as_deref())
|
||||
== Some(Some(path.as_str()));
|
||||
if !same {
|
||||
this.set_markdown(Some(path.clone().into()), text, cx);
|
||||
}
|
||||
} else {
|
||||
let same = this.code.as_ref().map(|code| code.path.as_str())
|
||||
== Some(path.as_str());
|
||||
if !same {
|
||||
this.set_code(path.clone().into(), text, window, cx);
|
||||
}
|
||||
}
|
||||
this.preview_bytes += text.len();
|
||||
}
|
||||
this.files.insert(path.clone(), kind);
|
||||
this.file_order.push_back(path);
|
||||
this.evict_previews();
|
||||
}
|
||||
Err(error) => {
|
||||
this.files
|
||||
.insert(path, FileContent::Failed(error.to_string()));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn drop_preview_of(&mut self, path: &str) {
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
self.commits.remove(path);
|
||||
|
||||
if self.selected_file.as_deref() == Some(path) {
|
||||
self.selected_file = None;
|
||||
}
|
||||
|
||||
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
|
||||
self.md = None;
|
||||
}
|
||||
|
||||
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
|
||||
self.code = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_previews(&mut self) {
|
||||
while (self.files.len() > MAX_PREVIEWED_FILES
|
||||
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
|
||||
&& self.file_order.len() > 1
|
||||
{
|
||||
let path = self.file_order.pop_front().expect("non-empty");
|
||||
|
||||
if Some(path.as_str()) == self.selected_file.as_deref() {
|
||||
self.file_order.push_back(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
|
||||
self.md = None;
|
||||
}
|
||||
|
||||
if self
|
||||
.code
|
||||
.as_ref()
|
||||
.is_some_and(|code| code.path.as_ref() == path.as_str())
|
||||
{
|
||||
self.code = None;
|
||||
}
|
||||
|
||||
self.commits.remove(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_commits.push(path.to_string());
|
||||
|
||||
if !self.loading_commits {
|
||||
self.load_commits(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_commits(&mut self, cx: &mut Context<Self>) {
|
||||
if self.pending_commits.is_empty() || self.loading_commits {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
self.pending_commits.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_commits = true;
|
||||
|
||||
let paths = std::mem::take(&mut self.pending_commits);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
let result = cx
|
||||
.background_spawn(
|
||||
async move { signed_git::worktree_last_commits(&worktree, &rels) },
|
||||
)
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.loading_commits = false;
|
||||
|
||||
if let Ok(found) = result {
|
||||
for (path, commit) in found {
|
||||
this.commits
|
||||
.insert(path.to_string_lossy().into_owned(), commit);
|
||||
}
|
||||
}
|
||||
|
||||
if !this.pending_commits.is_empty() {
|
||||
this.load_commits(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoFilesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
let pane_title = self.pane_title();
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(Self::render_tree_column(tree_state, view, cx))
|
||||
.child(self.render_content_column(pane_title, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn source_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Some common files are recognized by name rather than extension.
|
||||
match name {
|
||||
"Makefile" | "makefile" => return Some("make"),
|
||||
"CMakeLists.txt" => return Some("cmake"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"rs" => "rust",
|
||||
"toml" => "toml",
|
||||
"json" | "jsonc" => "json",
|
||||
"py" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" | "jsx" => "tsx",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"java" => "java",
|
||||
"kt" | "kts" | "ktm" => "kotlin",
|
||||
"swift" => "swift",
|
||||
"php" | "phtml" => "php",
|
||||
"rb" => "ruby",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"yml" | "yaml" => "yaml",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"html" | "htm" => "html",
|
||||
"lua" => "lua",
|
||||
"sql" => "sql",
|
||||
"proto" | "protobuf" => "proto",
|
||||
"cmake" => "cmake",
|
||||
"zig" => "zig",
|
||||
"ex" | "exs" => "elixir",
|
||||
"graphql" | "gql" => "graphql",
|
||||
"diff" | "patch" => "diff",
|
||||
"svelte" => "svelte",
|
||||
"astro" => "astro",
|
||||
"scala" => "scala",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
|
||||
use signed_git::CommitList;
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::placeholder;
|
||||
|
||||
use super::repo_display_name;
|
||||
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row};
|
||||
|
||||
pub(super) struct RepoHistoryView {
|
||||
store: Entity<RepoStore>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
worktree: Option<PathBuf>,
|
||||
all_commits: Option<CommitList>,
|
||||
loading_all_commits: bool,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoHistoryView {
|
||||
pub(super) fn new(store: Entity<RepoStore>, dock_area: WeakEntity<DockArea>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
worktree: None,
|
||||
all_commits: None,
|
||||
loading_all_commits: false,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_worktree(&mut self, path: Option<PathBuf>) {
|
||||
self.worktree = path;
|
||||
}
|
||||
|
||||
/// Number of commits reachable from HEAD, for the Commits tab badge.
|
||||
pub(super) fn commit_count(&self) -> Option<usize> {
|
||||
self.all_commits.as_ref().map(|list| list.total)
|
||||
}
|
||||
|
||||
/// Drop the current list and walk HEAD again.
|
||||
pub(super) fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.all_commits = None;
|
||||
self.loading_all_commits = false;
|
||||
self.load(cx);
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading_all_commits || self.all_commits.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_all_commits = true;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if let Ok(list) = result {
|
||||
let count = list.commits.len();
|
||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||
this.all_commits = Some(list);
|
||||
}
|
||||
|
||||
this.loading_all_commits = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub(super) fn open_commit_diff(
|
||||
&mut self,
|
||||
commit_id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Same display name as the repo detail panel's title.
|
||||
let repo_name = repo_display_name(self.store.read(cx));
|
||||
|
||||
let store = self.store.clone();
|
||||
let panel = cx.new(|cx| {
|
||||
CommitDiffView::new(store, worktree, repo_name, commit_id.into(), window, cx)
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoHistoryView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
-6
@@ -19,10 +19,8 @@ use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
|
||||
/// Shared state for the Init dialog, so async results can be rendered.
|
||||
pub type InitRepoState = DialogProgress;
|
||||
|
||||
/// Open the Init dialog for the local repository at `local_path`.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
@@ -51,7 +49,6 @@ pub fn open(
|
||||
.placeholder("Short description")
|
||||
});
|
||||
|
||||
// Load the user's grasp servers.
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
@@ -146,9 +143,6 @@ pub fn open(
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the init flow.
|
||||
///
|
||||
/// Closes the dialog and switches the repository into NIP-34 mode on success.
|
||||
fn init_repository(
|
||||
local_path: PathBuf,
|
||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||
+1000
-1586
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window};
|
||||
use gpui_component::combobox::ComboboxState;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
|
||||
pub(super) struct RefSwitcher {
|
||||
pub(super) branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
pub(super) tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
ref_branches: Vec<SharedString>,
|
||||
ref_tags: Vec<SharedString>,
|
||||
pub(super) switching_ref: bool,
|
||||
}
|
||||
|
||||
impl RefSwitcher {
|
||||
pub(super) fn new(window: &mut Window, cx: &mut App) -> Self {
|
||||
let branch_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
let tag_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
|
||||
Self {
|
||||
branch_select,
|
||||
tag_select,
|
||||
ref_branches: Vec::new(),
|
||||
ref_tags: Vec::new(),
|
||||
switching_ref: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_branches(
|
||||
&mut self,
|
||||
branches: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
selected,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn set_tags(
|
||||
&mut self,
|
||||
tags: Vec<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
|
||||
}
|
||||
|
||||
pub(super) fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
select.update(cx, |state, cx| match previous {
|
||||
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
|
||||
None => state.clear_selection(cx),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_selector(
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_items(SearchableVec::from(items.clone()), window, cx);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected_values(std::slice::from_ref(&value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Editor, EditorState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::helpers::{code_language, is_markdown_path};
|
||||
|
||||
/// Width of the file explorer column.
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
/// Files larger than this are not previewed.
|
||||
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
/// Preview cache caps, a file count and a text byte count.
|
||||
///
|
||||
/// The oldest previews are evicted beyond the caps.
|
||||
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
||||
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Preview state of a browsed file.
|
||||
pub(super) enum FileContent {
|
||||
/// Decodable text content.
|
||||
Text(String),
|
||||
/// Not valid UTF-8.
|
||||
Binary,
|
||||
/// Bigger than [`MAX_PREVIEW_BYTES`].
|
||||
TooLarge,
|
||||
/// Reading failed.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A markdown document loaded into a persistent [`TextViewState`].
|
||||
pub(super) struct MarkdownView {
|
||||
/// Source path, `None` means the repository README.
|
||||
pub(super) path: Option<SharedString>,
|
||||
pub(super) state: Entity<TextViewState>,
|
||||
}
|
||||
|
||||
/// A code file loaded into a persistent [`InputState`].
|
||||
pub(super) struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
pub(super) path: SharedString,
|
||||
pub(super) state: Entity<EditorState>,
|
||||
}
|
||||
|
||||
/// Spinner shown while a document is being loaded/parsed.
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// One row of the file tree with icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the file tree.
|
||||
pub(super) fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1().min_h_0().child(tree(
|
||||
&tree_state,
|
||||
move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// Right column, README, selected file preview or status text.
|
||||
pub(super) fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let loading = self.loading;
|
||||
let error = self.error.clone();
|
||||
let selected_file = self.selected_file.clone();
|
||||
|
||||
let body: AnyElement = if loading {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(Spinner::new().small())
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Cloning repository..."),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if let Some(error) = error {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(error),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if let Some(path) = selected_file {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()), cx)
|
||||
} else {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None, cx)
|
||||
} else {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
// Computed after the body above, which needs `&mut self`.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().muted)
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.xsmall()
|
||||
.text()
|
||||
.label(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("repo-content").flex_1().min_h_0().child(body))
|
||||
}
|
||||
|
||||
/// Load `text` into the persistent markdown TextView state.
|
||||
pub(super) fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let state = cx.new(|cx| TextViewState::markdown("", cx));
|
||||
state.update(cx, |state, cx| state.push_str(text, cx));
|
||||
self.md = Some(MarkdownView { path, state });
|
||||
}
|
||||
|
||||
/// The persistent markdown TextView for `path`, where `None` is the README.
|
||||
///
|
||||
/// Shows a spinner while the document is being loaded or parsed.
|
||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
TextView::new(&md.state)
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Load `text` into the persistent code editor state for `path`.
|
||||
///
|
||||
/// Code editor mode makes the Input render it read-only and highlighted.
|
||||
pub(super) fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let language = code_language(path.as_ref()).unwrap_or("text");
|
||||
let state = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
.language(language)
|
||||
.default_value(text)
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
self.code = Some(CodeView { path, state });
|
||||
}
|
||||
|
||||
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
|
||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
Editor::new(&code.state)
|
||||
.readonly(true)
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Context, Window, div, px};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
|
||||
use signed_git::FileCommit;
|
||||
use signed_ui::placeholder;
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::RepoDetailView;
|
||||
|
||||
/// Height of one commit row in the virtual list.
|
||||
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(super) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
// Copy only the values the element tree needs.
|
||||
// The list is borrowed by the renderer below instead of cloned per frame.
|
||||
// A full history can be tens of thousands of commits.
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
// The history is capped.
|
||||
// Tell the user the list is truncated.
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::TreeItem;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
|
||||
use utils::relative_time;
|
||||
|
||||
pub(super) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
id: String,
|
||||
/// File or directory name.
|
||||
label: String,
|
||||
children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
item = item.expanded(true);
|
||||
}
|
||||
item.children = seed
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect();
|
||||
item
|
||||
}
|
||||
|
||||
seeds
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Some common files are recognized by name rather than extension.
|
||||
match name {
|
||||
"Makefile" | "makefile" => return Some("make"),
|
||||
"CMakeLists.txt" => return Some("cmake"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"rs" => "rust",
|
||||
"toml" => "toml",
|
||||
"json" | "jsonc" => "json",
|
||||
"py" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" | "jsx" => "tsx",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"java" => "java",
|
||||
"kt" | "kts" | "ktm" => "kotlin",
|
||||
"swift" => "swift",
|
||||
"php" | "phtml" => "php",
|
||||
"rb" => "ruby",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"yml" | "yaml" => "yaml",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"html" | "htm" => "html",
|
||||
"lua" => "lua",
|
||||
"sql" => "sql",
|
||||
"proto" | "protobuf" => "proto",
|
||||
"cmake" => "cmake",
|
||||
"zig" => "zig",
|
||||
"ex" | "exs" => "elixir",
|
||||
"graphql" | "gql" => "graphql",
|
||||
"diff" | "patch" => "diff",
|
||||
"svelte" => "svelte",
|
||||
"astro" => "astro",
|
||||
"scala" => "scala",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||
pub(super) naddr: String,
|
||||
/// Hex ID of the announcement event itself.
|
||||
pub(super) event_id: String,
|
||||
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
|
||||
pub(super) coordinate: String,
|
||||
/// `https://gitworkshop.dev/<naddr>`
|
||||
pub(super) gitworkshop: String,
|
||||
/// `https://ditto.pub/<naddr>`
|
||||
pub(super) ditto: String,
|
||||
}
|
||||
|
||||
impl ShareTargets {
|
||||
pub(super) fn from_announcement(announcement: &Announcement) -> Self {
|
||||
let addr = announcement.addr();
|
||||
let coordinate = addr.to_string();
|
||||
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
|
||||
.to_bech32()
|
||||
.expect("a complete coordinate always encodes to naddr");
|
||||
|
||||
Self {
|
||||
naddr: naddr.clone(),
|
||||
event_id: announcement.event_id.to_bech32().unwrap(),
|
||||
coordinate,
|
||||
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
|
||||
ditto: format!("https://ditto.pub/{naddr}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The share dropdown menu, one row per target.
|
||||
///
|
||||
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
menu.min_w(px(340.))
|
||||
.item(menu_copy_row(
|
||||
"copy-gitworkshop",
|
||||
"GitWorkshop",
|
||||
truncate_naddr_link(&self.gitworkshop, 4),
|
||||
self.gitworkshop.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-ditto",
|
||||
"Ditto",
|
||||
truncate_naddr_link(&self.ditto, 4),
|
||||
self.ditto.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-event-id",
|
||||
"Event ID",
|
||||
middle_truncate(&self.event_id, 10, 10),
|
||||
self.event_id.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-coordinate",
|
||||
"Coordinate",
|
||||
middle_truncate(&self.coordinate, 10, 10),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
|
||||
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||
return url.to_string();
|
||||
};
|
||||
if url.len() - end <= tail + 3 {
|
||||
return url.to_string();
|
||||
}
|
||||
format!("{}...{}", &url[..end], &url[url.len() - tail..])
|
||||
}
|
||||
|
||||
/// Width of one line-number gutter in a diff row.
|
||||
pub(super) const GUTTER_WIDTH: f32 = 44.;
|
||||
/// Height of one row in a virtual diff list.
|
||||
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
/// One row of a virtual diff list, a hunk header or a line of a hunk.
|
||||
///
|
||||
/// Shared by the commit diff and pull request diff viewers.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
/// Line `line` of hunk `hunk` of the selected file's diff.
|
||||
Line { hunk: usize, line: usize },
|
||||
}
|
||||
|
||||
/// The rows of `file`'s diff, one header row per hunk then its lines.
|
||||
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// One row of the virtual diff list, a hunk header or a single line.
|
||||
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// One diff line, old and new line numbers in the gutters.
|
||||
///
|
||||
/// The content is tinted by kind, addition, deletion or context.
|
||||
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Find a tree item by id, searching into nested children.
|
||||
pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The root issue events of a repo store, for the shared detail sections.
|
||||
pub(super) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
/// The root pull request events of a repo store, for the shared detail sections.
|
||||
pub(super) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
/// The trigger body of the branch/tag selectors.
|
||||
///
|
||||
/// The kind icon, the selection or placeholder, and the caret.
|
||||
/// `Combobox` replaces its default trigger entirely,
|
||||
/// the only way to show an icon inside it.
|
||||
pub(super) fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Section heading of a detail sidebar, shared by the issue and PR panels.
|
||||
pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar with participants and labels of a root event, issue or PR.
|
||||
pub(super) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comments on a root event, issue or PR, one card per comment.
|
||||
pub(super) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comment form posting to an issue or PR root event.
|
||||
///
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(super) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_nested_tree_from_flat_entries() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||
|
||||
assert_eq!(items[1].label, "README.md");
|
||||
assert_eq!(items[1].id, "README.md");
|
||||
|
||||
assert_eq!(items[2].label, "docs");
|
||||
assert_eq!(items[2].children[0].label, "guide.md");
|
||||
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_handles_deep_nesting() {
|
||||
let entries = vec![
|
||||
PathBuf::from("a"),
|
||||
PathBuf::from("a/b"),
|
||||
PathBuf::from("a/b/c.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].children[0].id, "a/b");
|
||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
PathBuf::from("a/z.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "a");
|
||||
assert_eq!(items[0].children.len(), 2);
|
||||
assert_eq!(items[1].label, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_seeds_convert_to_tree_items() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/main.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
];
|
||||
|
||||
let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_language_maps_extensions_and_names() {
|
||||
assert_eq!(code_language("src/main.rs"), Some("rust"));
|
||||
assert_eq!(code_language("Cargo.toml"), Some("toml"));
|
||||
assert_eq!(code_language("app.js"), Some("javascript"));
|
||||
assert_eq!(code_language("index.tsx"), Some("tsx"));
|
||||
assert_eq!(code_language("Makefile"), Some("make"));
|
||||
assert_eq!(code_language("CMakeLists.txt"), Some("cmake"));
|
||||
assert_eq!(code_language("data.csv"), None);
|
||||
assert_eq!(code_language("LICENSE"), None);
|
||||
assert_eq!(code_language("README.md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naddr_link_keeps_url_and_tail() {
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
|
||||
"https://gitworkshop.dev/naddr1...1234"
|
||||
);
|
||||
// Without the naddr1 prefix, unchanged.
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://example.com/x", 4),
|
||||
"https://example.com/x"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
@@ -5,7 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
@@ -15,29 +16,40 @@ use gpui_component::{
|
||||
};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||
use signed_ui::{SegmentButton, UserAvatar};
|
||||
use signed_ui::{Avatar, SegmentButton};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::open_repo_panel;
|
||||
use super::{open_repo_panel, panel_avatar, tab_title};
|
||||
|
||||
const COLUMNS: usize = 2;
|
||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||
|
||||
/// How many of the newest repositories the `Recent` sort shows.
|
||||
const RECENT_COUNT: usize = 10;
|
||||
|
||||
/// Sort of the explore list, chosen via the header's filter buttons.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum RepoFilter {
|
||||
/// Every repository in the store's default order, newest first.
|
||||
All,
|
||||
#[default]
|
||||
/// Repositories ranked by total issues + pull requests + commits.
|
||||
Popular,
|
||||
/// The [`RECENT_COUNT`] newest repositories.
|
||||
Recent,
|
||||
}
|
||||
|
||||
impl AsRef<str> for RepoFilter {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
RepoFilter::All => "all",
|
||||
RepoFilter::Popular => "popular",
|
||||
RepoFilter::Recent => "recent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RepoFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoFilter {
|
||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||
///
|
||||
@@ -49,6 +61,7 @@ impl RepoFilter {
|
||||
// Narrow by the search query first.
|
||||
// Recent then limits the matches and Popular ranks them.
|
||||
let query = query.trim().to_lowercase();
|
||||
|
||||
if !query.is_empty() {
|
||||
indices.retain(|&ix| {
|
||||
let announcement = &announcements[ix];
|
||||
@@ -82,23 +95,18 @@ impl RepoFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse all announced repositories.
|
||||
pub struct RepoListView {
|
||||
store: Entity<RepoListStore>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Sort selected in the header filter buttons.
|
||||
filter: RepoFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
|
||||
repo_len: usize,
|
||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||
visible: Vec<usize>,
|
||||
/// Search box filtering repositories by name.
|
||||
search: Entity<InputState>,
|
||||
/// Rebuilds the visible slice as the search text changes.
|
||||
_search_subscription: Subscription,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
@@ -111,7 +119,6 @@ impl RepoListView {
|
||||
) -> Self {
|
||||
let store = RepoListStore::global(cx);
|
||||
|
||||
// Live search over repository names
|
||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
@@ -125,7 +132,11 @@ impl RepoListView {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
focus_handle: cx.focus_handle(),
|
||||
@@ -137,27 +148,19 @@ impl RepoListView {
|
||||
search,
|
||||
_search_subscription: search_subscription,
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
// Seed the rows right away.
|
||||
// The store may already hold announcements from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.rebuild_rows(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
|
||||
///
|
||||
/// Uses the store contents, [`Self::filter`] and the search query.
|
||||
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
let query = self.search.read(cx).value();
|
||||
let store = self.store.read(cx);
|
||||
|
||||
self.visible = filter.visible(store, &query);
|
||||
|
||||
// Each virtual list row holds `COLUMNS` repo cards.
|
||||
let rows = self.visible.len().div_ceil(COLUMNS);
|
||||
|
||||
if self.repo_len != rows {
|
||||
self.repo_len = rows;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
|
||||
@@ -172,7 +175,13 @@ impl RepoListView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
open_repo_panel(
|
||||
&self.dock_area,
|
||||
&announcement.addr(),
|
||||
Some(announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_card(
|
||||
@@ -280,7 +289,7 @@ impl RepoListView {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(owner.name()).picture(owner.picture()))
|
||||
.child(Avatar::new(owner.name()).picture(owner.picture()))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
@@ -308,6 +317,22 @@ impl RepoListView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_filter<T>(&self, filter: RepoFilter, label: T, cx: &mut Context<Self>) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(filter.to_string(), label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -315,18 +340,24 @@ impl RepoListView {
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().font_semibold().child("Repositories"))
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.w_10()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.line_height(relative(1.2))
|
||||
.child("Repositories"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!("({count})"))),
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::from(format!("Total: {count}"))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -342,31 +373,12 @@ impl RepoListView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.filter_button(RepoFilter::All, "All", cx))
|
||||
.child(self.filter_button(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.filter_button(RepoFilter::Recent, "Recent", cx)),
|
||||
.child(self.render_filter(RepoFilter::All, "All", cx))
|
||||
.child(self.render_filter(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.render_filter(RepoFilter::Recent, "Recent", cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One segmented header filter button, like the issues list's status filter buttons.
|
||||
fn filter_button(
|
||||
&self,
|
||||
filter: RepoFilter,
|
||||
label: &'static str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(label, label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for RepoListView {
|
||||
@@ -377,7 +389,7 @@ impl BasePanel for RepoListView {
|
||||
|
||||
impl Panel for RepoListView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().text_sm().child(SharedString::from("Explore"))
|
||||
tab_title(panel_avatar("explore"), SharedString::from("Explore"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-10
@@ -11,21 +11,16 @@ use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
|
||||
pub struct SendPatchView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the panel lives in.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Store of the target repository.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Title input, required.
|
||||
subject: Entity<InputState>,
|
||||
/// Description input, optional.
|
||||
description: Entity<TextareaState>,
|
||||
/// The pasted `git format-patch` output, required.
|
||||
patch: Entity<TextareaState>,
|
||||
/// A submit is in flight.
|
||||
submitting: bool,
|
||||
/// Error of the last submit attempt, it keeps the panel open.
|
||||
error: Option<SharedString>,
|
||||
@@ -61,7 +56,6 @@ impl SendPatchView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the pull request from the pasted patch.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting {
|
||||
return;
|
||||
@@ -213,8 +207,11 @@ impl BasePanel for SendPatchView {
|
||||
}
|
||||
|
||||
impl Panel for SendPatchView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(SharedString::from(format!("{}/send-patch", self.repo_name)))
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,9 @@ use super::super::open_repo_panel;
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
|
||||
/// Open the Create Repository dialog.
|
||||
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
@@ -151,7 +150,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick the repository's storage folder with the platform's native folder picker.
|
||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
@@ -186,8 +184,6 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Run the create-repository flow.
|
||||
///
|
||||
/// Opens the new working copy and the repository panel on success.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_repository(
|
||||
@@ -250,12 +246,17 @@ fn create_repository(
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Open the newly created repository in the dock's center.
|
||||
fn open_repo(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
announcement: Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
open_repo_panel(&dock_area, &announcement, window, cx);
|
||||
open_repo_panel(
|
||||
&dock_area,
|
||||
&announcement.addr(),
|
||||
Some(&announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,20 +8,21 @@ 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 {
|
||||
/// The user's grasp list of kind `10317` is being loaded.
|
||||
/// Set while the user's kind `10317` grasp list loads.
|
||||
pub loading_servers: bool,
|
||||
pub grasp_servers: Vec<RelayUrl>,
|
||||
/// Whether the grasp server section is shown. Defaults to shown.
|
||||
pub servers_enabled: bool,
|
||||
/// Error of the last grasp-server edit, an invalid relay URL for example.
|
||||
/// Error from the last grasp-server edit, such as an invalid relay URL.
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl GraspServersState {
|
||||
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
|
||||
/// Defaults used until the user's grasp list loads and replaces them.
|
||||
///
|
||||
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
|
||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||
@@ -137,7 +138,6 @@ pub fn grasp_servers_field(
|
||||
}))
|
||||
}
|
||||
|
||||
/// One grasp server row, the host in a tag plus a remove button.
|
||||
fn render_server_row(
|
||||
ix: usize,
|
||||
relay: &RelayUrl,
|
||||
@@ -157,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}"))
|
||||
@@ -176,15 +176,7 @@ fn render_server_row(
|
||||
)
|
||||
}
|
||||
|
||||
/// The bare host of a grasp server, defaults are entered without a scheme.
|
||||
fn display_server(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
/// Parse the relay input, accepting a bare host, and append it to the list.
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
@@ -196,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) {
|
||||
@@ -212,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());
|
||||
});
|
||||
@@ -220,24 +206,28 @@ fn add_relay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the user's grasp list of kind `10317` from the local database.
|
||||
/// Loads the user's kind `10317` grasp list from the local database.
|
||||
///
|
||||
/// It replaces the defaults when it lists any servers.
|
||||
/// Replaces the defaults when the list is non-empty.
|
||||
pub fn load_user_grasp_servers(
|
||||
state: Entity<GraspServersState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
let (user, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
state.update(cx, |state, _| state.loading_servers = false);
|
||||
return;
|
||||
};
|
||||
let client = backend.read(cx).client();
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result = signed_state::user_grasp_list_servers(client, user).await;
|
||||
let result = signed_state::user_grasp_list_servers(&client, user).await;
|
||||
|
||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use gpui::{App, Window, px};
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
/// Open the Import Identity dialog.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
dialog.title("Import identity").width(px(400.))
|
||||
|
||||
@@ -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,18 +10,20 @@ 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};
|
||||
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
@@ -34,20 +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>>,
|
||||
/// Artwork for the sign-in screen.
|
||||
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>>,
|
||||
/// A local scan is currently running.
|
||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||
scanning: bool,
|
||||
/// Unpushed local commits per announced repository, the row badge counts.
|
||||
/// Unpushed commit counts per announced repository.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -66,35 +79,29 @@ impl SidebarPanel {
|
||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||
|
||||
if !signer_changed && !signer_required {
|
||||
return;
|
||||
}
|
||||
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if this.refresh(cx) || signer_required {
|
||||
if this.refresh(cx) || signer_changed {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// The merged list re-derives when announcements or the local scan change.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// The local scan re-derives when announcements or the local scan change.
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Push statuses are recomputed in the background; only the badge counts change.
|
||||
// Push statuses are recomputed in the background, so only the badge counts change.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_unpushed(cx) {
|
||||
cx.notify();
|
||||
@@ -119,31 +126,19 @@ 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).read(cx);
|
||||
|
||||
// A scanned repository is dropped from the local list
|
||||
// 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).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;
|
||||
@@ -162,14 +157,13 @@ impl SidebarPanel {
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
}
|
||||
|
||||
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
|
||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let checkouts = CheckoutsStore::global(cx).read(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.unpushed(&addr);
|
||||
let count = checkouts.read(cx).unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
@@ -183,7 +177,6 @@ impl SidebarPanel {
|
||||
true
|
||||
}
|
||||
|
||||
/// Keep the `ready to push` statuses of the announced repositories current.
|
||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
@@ -193,7 +186,6 @@ impl SidebarPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the inbox home panel in the dock area's center.
|
||||
pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() {
|
||||
return;
|
||||
@@ -202,12 +194,13 @@ impl SidebarPanel {
|
||||
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx));
|
||||
self.inbox = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Open the Explore repository list panel in the dock area's center.
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.explore
|
||||
@@ -221,12 +214,13 @@ impl SidebarPanel {
|
||||
let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx));
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Show the Onboarding dialog.
|
||||
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name"));
|
||||
let pass_input = cx.new(|cx| {
|
||||
@@ -244,28 +238,86 @@ impl SidebarPanel {
|
||||
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
|
||||
}
|
||||
|
||||
/// Show the Create Repository dialog.
|
||||
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
create_repo_dialog::open(self.dock_area.clone(), window, cx);
|
||||
}
|
||||
|
||||
/// Open a repository's detail view in the dock's center.
|
||||
fn open_repo(
|
||||
&mut self,
|
||||
announcement: &Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
open_repo_panel(
|
||||
&self.dock_area,
|
||||
&announcement.addr(),
|
||||
Some(announcement),
|
||||
window,
|
||||
&mut *cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a local repository's detail view in the dock's center.
|
||||
///
|
||||
/// 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);
|
||||
@@ -324,7 +376,7 @@ impl SidebarPanel {
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
// Merged list, the user's NIP-34 repositories and local repositories discovered.
|
||||
// The merged list: NIP-34 repositories first, then discovered local repositories.
|
||||
let total = announcements.len() + local_repos.len();
|
||||
|
||||
if total == 0 {
|
||||
@@ -364,11 +416,10 @@ impl SidebarPanel {
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of the merged sidebar list, a NIP-34 or a local repository.
|
||||
fn render_repo_at(
|
||||
&self,
|
||||
announcements: &[Announcement],
|
||||
local_repos: &[PathBuf],
|
||||
local_repos: &[ResolvedLocalRepo],
|
||||
ix: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
@@ -379,9 +430,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(
|
||||
@@ -390,10 +441,11 @@ impl SidebarPanel {
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = announcement.name().map(SharedString::from);
|
||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
// The sidebar rows are compact, so the avatar matches the 16px nav icons.
|
||||
let avatar =
|
||||
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||
let announcement = announcement.clone();
|
||||
|
||||
// Badge with the unpushed commit count of the repository's local checkouts.
|
||||
let unpushed = self
|
||||
.unpushed
|
||||
.get(&announcement.addr())
|
||||
@@ -423,34 +475,60 @@ impl SidebarPanel {
|
||||
)
|
||||
}
|
||||
|
||||
/// One local repository row.
|
||||
///
|
||||
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
|
||||
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);
|
||||
}))
|
||||
}
|
||||
|
||||
/// Show the Import Identity dialog.
|
||||
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
import_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
/// Render the user avatar and name in the sidebar, inside the titlebar drag area.
|
||||
/// The user avatar and name, wired into the titlebar drag area.
|
||||
fn render_user(
|
||||
&self,
|
||||
profile: &Profile,
|
||||
@@ -470,7 +548,7 @@ impl SidebarPanel {
|
||||
Button::new("user").text().dropdown_caret(true).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_xs().font_semibold().child(name)),
|
||||
),
|
||||
),
|
||||
@@ -480,7 +558,7 @@ impl SidebarPanel {
|
||||
)
|
||||
}
|
||||
|
||||
/// Sign-in placeholder shown while logged out.
|
||||
/// Shown while no identity is signed in.
|
||||
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
v_flex()
|
||||
.size_full()
|
||||
@@ -550,6 +628,51 @@ impl SidebarPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
||||
///
|
||||
/// Returns the text to store and the parsed relay URL, or `None` when it is not a valid relay URL.
|
||||
pub(super) fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
|
||||
let text = if input.contains("://") {
|
||||
input.to_owned()
|
||||
} else {
|
||||
format!("wss://{input}")
|
||||
};
|
||||
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
|
||||
}
|
||||
|
||||
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
|
||||
pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
|
||||
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
||||
// Matches the other sidebar rows, and the badge below sits at its corner.
|
||||
let avatar = PixelAvatar::new(entry.path.to_string_lossy()).xsmall();
|
||||
|
||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||
return avatar.into_any_element();
|
||||
};
|
||||
|
||||
let store = ProfileStore::global(cx);
|
||||
let profile = store.read(cx).get(&owner);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.child(avatar)
|
||||
.child(
|
||||
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
||||
Avatar::new(profile.name())
|
||||
.picture(profile.picture())
|
||||
.size(px(14.)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn pick_banner() -> SharedString {
|
||||
let num = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -599,9 +722,9 @@ impl Render for SidebarPanel {
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
|
||||
@@ -9,10 +9,9 @@ use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the Onboarding dialog, so async results can be rendered.
|
||||
/// Progress of the onboarding flow, so async results can be rendered.
|
||||
pub type OnboardingState = DialogProgress;
|
||||
|
||||
/// Open the Onboarding dialog for creating a new identity.
|
||||
pub fn open(
|
||||
name_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
|
||||
@@ -10,16 +10,14 @@ use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the passphrase dialog, so async results can be rendered.
|
||||
/// State of the passphrase dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct PassphraseState {
|
||||
/// Progress of the unlock flow.
|
||||
pub progress: DialogProgress,
|
||||
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
|
||||
_enter_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
/// Open the dialog asking for the passphrase that protects the stored identity.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let pass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -30,7 +28,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let state = cx.new(|_| PassphraseState::default());
|
||||
|
||||
// Enter in the passphrase field submits, same as the Unlock button.
|
||||
// Enter in the passphrase field submits, like the Unlock button.
|
||||
let enter_pass_input = pass_input.clone();
|
||||
let enter_state = state.clone();
|
||||
let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| {
|
||||
@@ -95,7 +93,6 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Submit the passphrase to the backend.
|
||||
fn unlock(
|
||||
pass_input: &Entity<InputState>,
|
||||
state: &Entity<PassphraseState>,
|
||||
|
||||
@@ -18,11 +18,12 @@ 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 settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
|
||||
/// The index of `value` in `options`, for seeding a [`SelectState`].
|
||||
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
|
||||
.iter()
|
||||
@@ -30,7 +31,7 @@ fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
||||
.map(|row| IndexPath::default().row(row))
|
||||
}
|
||||
|
||||
/// The light and dark themes registered in the theme registry.
|
||||
/// Registered themes split into light and dark options, light first.
|
||||
fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let mut light = Vec::new();
|
||||
@@ -48,9 +49,10 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
||||
(light, dark)
|
||||
}
|
||||
|
||||
/// Stateful controls of the settings dialog, created once when it opens.
|
||||
/// Created once when the dialog opens, so control state survives re-renders.
|
||||
struct SettingsControls {
|
||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
font_size: Entity<InputState>,
|
||||
@@ -58,9 +60,9 @@ struct SettingsControls {
|
||||
radius: Entity<InputState>,
|
||||
radius_lg: Entity<InputState>,
|
||||
grasp_server_input: Entity<InputState>,
|
||||
/// The effective default create-repository folder, shown in the disabled input.
|
||||
/// The effective create-repository folder, shown in a disabled input.
|
||||
default_folder: Entity<InputState>,
|
||||
/// Keeps the control subscriptions alive for the dialog's lifetime.
|
||||
/// Keeps the control subscriptions alive while the dialog is open.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -88,6 +90,23 @@ impl SettingsControls {
|
||||
)
|
||||
});
|
||||
|
||||
let event_fetching_options = vec![
|
||||
SelectOption::new("curated", "Curated"),
|
||||
SelectOption::new("uncensored", "Uncensored"),
|
||||
];
|
||||
let event_fetching_value = match settings.event_fetching {
|
||||
EventFetchingStrategy::Curated => "curated",
|
||||
EventFetchingStrategy::Uncensored => "uncensored",
|
||||
};
|
||||
let event_fetching = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
event_fetching_options.clone(),
|
||||
selected_index(&event_fetching_options, event_fetching_value),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let (light_options, dark_options) = theme_options(cx);
|
||||
let light_theme = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
@@ -146,6 +165,19 @@ impl SettingsControls {
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&event_fetching, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let strategy = match value.as_ref() {
|
||||
"curated" => EventFetchingStrategy::Curated,
|
||||
_ => EventFetchingStrategy::Uncensored,
|
||||
};
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.event_fetching = strategy, cx);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let store = SettingsStore::global(cx);
|
||||
@@ -251,6 +283,7 @@ impl SettingsControls {
|
||||
|
||||
Self {
|
||||
appearance,
|
||||
event_fetching,
|
||||
light_theme,
|
||||
dark_theme,
|
||||
font_size,
|
||||
@@ -264,7 +297,6 @@ impl SettingsControls {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Settings dialog.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let controls = Rc::new(SettingsControls::new(window, cx));
|
||||
|
||||
@@ -278,8 +310,6 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The settings content, one section per related setting.
|
||||
/// Sections are divided by horizontal separator lines.
|
||||
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
@@ -292,12 +322,15 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
||||
.child(Separator::horizontal())
|
||||
.child(theme_section(&settings, controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(tab_bar_section(&settings, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(grasp_servers_section(&settings, controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(event_fetching_section(controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(repositories_section(&settings, controls, cx))
|
||||
}
|
||||
|
||||
/// How the app picks its appearance.
|
||||
fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
cx,
|
||||
@@ -307,7 +340,16 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
|
||||
))
|
||||
}
|
||||
|
||||
/// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
|
||||
/// Which relays repository activity is fetched from.
|
||||
fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
cx,
|
||||
"Event Fetching Strategy",
|
||||
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
|
||||
Select::new(&controls.event_fetching).w_full(),
|
||||
))
|
||||
}
|
||||
|
||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
@@ -378,7 +420,27 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
|
||||
))
|
||||
}
|
||||
|
||||
/// Default grasp servers offered until the user publishes a kind `10317` grasp list.
|
||||
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
cx,
|
||||
"Hide Tab Navigation Buttons",
|
||||
"Enable to show navigation buttons when more than 10 tabs.",
|
||||
Switch::new("hide-tab-navigation")
|
||||
.checked(settings.tab_bar.hide_navigation_buttons)
|
||||
.on_click(move |checked: &bool, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| settings.tab_bar.hide_navigation_buttons = *checked,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Default grasp servers, used until the user's kind `10317` grasp list loads.
|
||||
fn grasp_servers_section(
|
||||
settings: &Settings,
|
||||
controls: &SettingsControls,
|
||||
@@ -394,7 +456,6 @@ fn grasp_servers_section(
|
||||
))
|
||||
}
|
||||
|
||||
/// The editable list of default grasp servers plus an add-relay input.
|
||||
/// Styled like the grasp-server section of the publish dialogs.
|
||||
fn grasp_server_editor(
|
||||
servers: &[String],
|
||||
@@ -459,17 +520,14 @@ fn grasp_server_editor(
|
||||
)
|
||||
}
|
||||
|
||||
/// The bare host of a grasp server, defaults are entered without a scheme.
|
||||
/// Matches how the publish dialogs display servers.
|
||||
/// Shows only the host, since grasp servers are entered without a scheme.
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Local repository scanning and the create-repository dialog default folder.
|
||||
fn repositories_section(
|
||||
settings: &Settings,
|
||||
controls: &SettingsControls,
|
||||
@@ -494,7 +552,6 @@ fn repositories_section(
|
||||
))
|
||||
}
|
||||
|
||||
/// The editable list of scan directories plus an add-directory button.
|
||||
/// Styled like the grasp-server list.
|
||||
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
@@ -547,7 +604,6 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
||||
)
|
||||
}
|
||||
|
||||
/// The default-folder selector, a disabled input plus a picker button.
|
||||
/// Matches the create-repository dialog.
|
||||
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
||||
let default_folder = controls.default_folder.clone();
|
||||
@@ -571,21 +627,15 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse the server input and append it to the default grasp servers.
|
||||
/// A bare host is accepted.
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
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| {
|
||||
@@ -604,7 +654,6 @@ fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
|
||||
/// Prompt for directories to add to the local-repository scan.
|
||||
fn add_scan_path(cx: &mut App) {
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
@@ -641,8 +690,7 @@ fn add_scan_path(cx: &mut App) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Prompt for the Create Repository dialog's default folder.
|
||||
/// Remember it in the settings and show it in the disabled input.
|
||||
/// Persists the choice and reflects it in the disabled input.
|
||||
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let default_folder = default_folder.clone();
|
||||
@@ -676,9 +724,7 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Wire a number input to the settings.
|
||||
/// Step actions clamp and persist the value.
|
||||
/// Typed changes parse, clamp and persist.
|
||||
/// Step actions clamp and persist the value; typed changes parse, clamp and persist.
|
||||
fn wire_number_input(
|
||||
state: &Entity<InputState>,
|
||||
subscriptions: &mut Vec<Subscription>,
|
||||
@@ -751,7 +797,6 @@ fn wire_number_input(
|
||||
}));
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance to the live theme.
|
||||
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
@@ -760,7 +805,6 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-apply the persisted theme configuration to the live theme.
|
||||
fn apply_theme(cx: &mut App) {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().theme.clone();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui_component::tree::TreeItem;
|
||||
|
||||
pub(crate) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
pub(crate) id: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
item = item.expanded(true);
|
||||
}
|
||||
item.children = seed
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect();
|
||||
item
|
||||
}
|
||||
|
||||
seeds
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// Sorted relative paths of a worktree snapshot.
|
||||
///
|
||||
/// Compared against the `worktree_paths` of a repository panel to skip
|
||||
/// rebuilding the explorer when a refresh left the worktree unchanged.
|
||||
pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
|
||||
let mut paths: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_nested_tree_from_flat_entries() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||
|
||||
assert_eq!(items[1].label, "README.md");
|
||||
assert_eq!(items[1].id, "README.md");
|
||||
|
||||
assert_eq!(items[2].label, "docs");
|
||||
assert_eq!(items[2].children[0].label, "guide.md");
|
||||
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_handles_deep_nesting() {
|
||||
let entries = vec![
|
||||
PathBuf::from("a"),
|
||||
PathBuf::from("a/b"),
|
||||
PathBuf::from("a/b/c.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].children[0].id, "a/b");
|
||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
PathBuf::from("a/z.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "a");
|
||||
assert_eq!(items[0].children.len(), 2);
|
||||
assert_eq!(items[1].label, "b");
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,22 @@
|
||||
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, KeyBinding, Render, Subscription, Window, actions, div, px};
|
||||
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||
use gpui_component::{Root, StyledExt, Theme};
|
||||
use gpui_fps::{FpsMonitor, FpsOverlay};
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use crate::views::SidebarPanel;
|
||||
use crate::views::sidebar::passphrase_dialog;
|
||||
|
||||
actions!(workspace, [ToggleMonitor]);
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
fps: Entity<FpsMonitor>,
|
||||
/// Debug HUD, toggled with `cmd-shift-f`.
|
||||
show_fps: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_passphrase_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let fps = cx.new(|cx| FpsMonitor::new(window, cx).continuous(false));
|
||||
cx.bind_keys([KeyBinding::new("cmd-shift-f", ToggleMonitor, None)]);
|
||||
let backend = Backend::global(cx);
|
||||
let settings = SettingsStore::global(cx);
|
||||
|
||||
let dock = cx.new(|cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
@@ -33,20 +27,15 @@ impl Workspace {
|
||||
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
||||
let weak_sidebar = sidebar.downgrade();
|
||||
|
||||
dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
if settings.read(cx).settings().appearance == AppearanceMode::System {
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
}
|
||||
|
||||
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
||||
let dock_for_pruning = dock.clone();
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&dock,
|
||||
window,
|
||||
@@ -54,9 +43,9 @@ impl Workspace {
|
||||
if !matches!(event, DockEvent::LayoutChanged) {
|
||||
return;
|
||||
}
|
||||
let dock = dock_for_pruning.clone();
|
||||
let weak = weak_dock.clone();
|
||||
cx.spawn_in(window, async move |_, window| {
|
||||
dock.update_in(window, |area, window, cx| {
|
||||
weak.update_in(window, |area, window, cx| {
|
||||
for placement in [DockPlacement::Bottom, DockPlacement::Right] {
|
||||
if area.is_empty(placement, cx) {
|
||||
area.remove_dock(placement, window, cx);
|
||||
@@ -69,28 +58,34 @@ impl Workspace {
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
||||
let passphrase_subscription =
|
||||
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&backend,
|
||||
window,
|
||||
|_this, _state, event, window, cx| {
|
||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
this.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
// Open the explore panel after the sidebar has been initialized.
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
weak_sidebar
|
||||
.update(cx, |this, cx| {
|
||||
this.open_explore(window, cx);
|
||||
@@ -100,10 +95,7 @@ impl Workspace {
|
||||
|
||||
Self {
|
||||
dock,
|
||||
show_fps: cfg!(debug_assertions),
|
||||
fps,
|
||||
_subscriptions: subscriptions,
|
||||
_passphrase_subscription: passphrase_subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,21 +107,11 @@ impl Render for Workspace {
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.on_action(
|
||||
cx.listener(|this: &mut Self, _ev: &ToggleMonitor, _window, cx| {
|
||||
this.show_fps = !this.show_fps;
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(self.dock.clone())
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(dialog_layer)
|
||||
// On top of everything, so it stays readable while debugging.
|
||||
.when(self.show_fps, |this| this.child(FpsOverlay::new(&self.fps)))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user